From f0782f26e5511403ae8411a8a45b2fb4031b9e15 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 13 Aug 2026 14:36:06 +0300 Subject: [PATCH 01/67] feat(game-center): level-focused arcade HUD for the progress snapshot Replace the progress snapshot's stat pills and separate level circle with a single level-first HUD: a brand banner (level badge, XP to next level, total XP) with a segmented XP bar, and an icon stat row for streak, longest streak, and achievements. Uses the design-system icons and brand tokens, keeping the milestone and closest-achievement cards intact. Co-Authored-By: Claude Opus 4.8 --- .../components/game-center/TrophyShelf.tsx | 95 +++++ packages/webapp/lib/gameCenter.spec.ts | 32 +- packages/webapp/lib/gameCenter.ts | 101 +++++- packages/webapp/pages/game-center/index.tsx | 330 ++++++++++-------- 4 files changed, 415 insertions(+), 143 deletions(-) create mode 100644 packages/webapp/components/game-center/TrophyShelf.tsx diff --git a/packages/webapp/components/game-center/TrophyShelf.tsx b/packages/webapp/components/game-center/TrophyShelf.tsx new file mode 100644 index 00000000000..c2f1c34f640 --- /dev/null +++ b/packages/webapp/components/game-center/TrophyShelf.tsx @@ -0,0 +1,95 @@ +import type { CSSProperties, 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, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; +import type { TrophyShelfItem } from '../../lib/gameCenter'; + +type TrophyProps = { + item: TrophyShelfItem; +}; + +const Trophy = ({ item }: TrophyProps): ReactElement => { + const { name, image, count, size, imageGlow } = item; + const glowStyle: CSSProperties | undefined = imageGlow + ? { + background: `radial-gradient(closest-side, ${imageGlow}, transparent 72%)`, + } + : undefined; + + return ( + +
+
+ {glowStyle && ( + + )} + {name} + + + ×{count.toLocaleString()} + + +
+ + {name} + +
+
+ ); +}; + +type TrophyShelfProps = { + shelves: TrophyShelfItem[][]; +}; + +export const TrophyShelf = ({ shelves }: TrophyShelfProps): ReactElement => { + return ( +
+ {shelves.map((row) => ( +
item.id).join('-')} + className="flex flex-col" + > +
+ {row.map((item) => ( + + ))} +
+
+ +
+
+ ))} +
+ ); +}; diff --git a/packages/webapp/lib/gameCenter.spec.ts b/packages/webapp/lib/gameCenter.spec.ts index 46a2cdfa432..ada862c5506 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, + getTrophyShelves, } from './gameCenter'; const createQuest = ( @@ -348,4 +352,30 @@ describe('game center helpers', () => { 'award-3', ]); }); + + it('sizes trophies by rarity (award value), rarest first and largest', () => { + 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 { shelves } = getAwardSummary(awards, catalog); + const flat = shelves.flat(); + + // rarest (highest value) first, regardless of how many were earned + expect(flat.map((item) => item.id)).toEqual(['b', 'c', 'a']); + // and it renders bigger the rarer it is + expect(flat[0].size).toBeGreaterThan(flat[1].size); + expect(flat[1].size).toBeGreaterThan(flat[2].size); + }); + + it('returns no shelves when there are no awards', () => { + expect(getTrophyShelves([])).toEqual([]); + }); }); diff --git a/packages/webapp/lib/gameCenter.ts b/packages/webapp/lib/gameCenter.ts index 1ab04e5eab8..473636bcb2d 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, @@ -275,20 +278,116 @@ const sortAwardsByCount = ( }); }; +export type AwardWithRarity = UserProductSummary & { + value: number; + imageGlow?: string | null; +}; + +export type TrophyShelfItem = AwardWithRarity & { size: number }; + export type GameCenterAwardSummary = { awards: UserProductSummary[]; + shelves: TrophyShelfItem[][]; totalAwards: number; uniqueAwards: number; favoriteAward: UserProductSummary | null; }; +// Trophies scale with rarity (an award's Cores value). Sizes are in px and are +// consumed as inline width so the shelf can render server-side without JS. +const TROPHY_SIZE_MIN = 64; +const TROPHY_SIZE_MAX = 148; +// Width a single shelf row tries to fill before wrapping to the next plank. +const SHELF_ROW_BUDGET = 540; +const TROPHY_GAP = 32; + +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, + }; + }); +}; + +const getTrophySize = (value: number, min: number, max: number): number => { + if (max <= min) { + return Math.round((TROPHY_SIZE_MIN + TROPHY_SIZE_MAX) / 2); + } + // sqrt easing keeps the cheapest awards from collapsing to nothing while the + // rarest still tower over them. + const ratio = Math.sqrt((value - min) / (max - min)); + return Math.round( + TROPHY_SIZE_MIN + ratio * (TROPHY_SIZE_MAX - TROPHY_SIZE_MIN), + ); +}; + +// Rarest-first, packed into shelf rows by width so the big trophies get their +// own roomy plank up top and the commons cluster below. +export const getTrophyShelves = ( + awards: AwardWithRarity[], +): TrophyShelfItem[][] => { + if (awards.length === 0) { + return []; + } + + const sorted = [...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); + }); + + const values = sorted.map((award) => award.value); + const min = Math.min(...values); + const max = Math.max(...values); + + const items: TrophyShelfItem[] = sorted.map((award) => ({ + ...award, + size: getTrophySize(award.value, min, max), + })); + + const shelves: TrophyShelfItem[][] = []; + let row: TrophyShelfItem[] = []; + let rowWidth = 0; + items.forEach((item) => { + const itemWidth = item.size + TROPHY_GAP; + if (row.length > 0 && rowWidth + itemWidth > SHELF_ROW_BUDGET) { + shelves.push(row); + row = []; + rowWidth = 0; + } + row.push(item); + rowWidth += itemWidth; + }); + if (row.length > 0) { + shelves.push(row); + } + + return shelves; +}; + export const getAwardSummary = ( awards?: UserProductSummary[], + products?: Product[], ): GameCenterAwardSummary => { const allAwards = sortAwardsByCount(awards ?? []); + const shelves = getTrophyShelves( + enrichAwardsWithRarity(allAwards, products ?? []), + ); return { awards: allAwards, + shelves, totalAwards: allAwards.reduce((total, award) => total + 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..8899b4444bc 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -14,6 +14,7 @@ import { QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; import { + getProductsQueryOptions, ProductType, userProductSummaryQueryOptions, } from '@dailydotdev/shared/src/graphql/njord'; @@ -58,7 +59,6 @@ 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'; @@ -70,10 +70,7 @@ import { } 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 { 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 type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; @@ -82,12 +79,16 @@ import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ArrowIcon, CoreIcon, + HotIcon, MedalBadgeIcon, PinIcon, + ReputationLightningIcon, + StarIcon, } from '@dailydotdev/shared/src/components/icons'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; +import { TrophyShelf } from '../../components/game-center/TrophyShelf'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; import { @@ -176,49 +177,157 @@ const EmptyStateCard = ({ ); }; -const StatPill = ({ +const xpSegmentCount = 10; + +const HudStatTile = ({ + icon, label, value, }: { + icon: ReactElement; label: string; value: string; }): ReactElement => ( -
- - {label} - - +
+
+ {icon} + + {label} + +
+ {value}
); -const TrophyCard = ({ - name, - image, - count, +const LevelHud = ({ + level, + levelProgress, + totalXp, + xpToNextLevel, + currentStreak, + longestStreak, + achievements, + isPending, }: { - name: string; - image: string; - count: number; + level: number; + levelProgress: number; + totalXp: number; + xpToNextLevel: number; + currentStreak: number; + longestStreak: number; + achievements?: { unlocked: number; total: number }; + isPending: boolean; }): ReactElement => { + const filledSegments = Math.round((levelProgress / 100) * xpSegmentCount); + const streakValue = isPending ? '...' : `${currentStreak.toLocaleString()}d`; + const longestValue = isPending ? '...' : `${longestStreak.toLocaleString()}d`; + return ( - +
+
+
+
+
+ + LVL + + + {level} + +
+ + {xpToNextLevel.toLocaleString()} XP to level {level + 1} + +
+
+
+ + + {totalXp.toLocaleString()} + +
+ + total XP + +
+
+
+ {Array.from({ length: xpSegmentCount }, (_, index) => ( + + ))} +
+
- + } + label="Streak" + value={streakValue} /> - - x{count.toLocaleString()} - + + } + label="Longest" + value={longestValue} + /> + {achievements ? ( + + } + label="Badges" + value={`${achievements.unlocked}/${achievements.total}`} + /> + ) : null}
- +
); }; @@ -342,9 +451,17 @@ 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 @@ -702,20 +819,7 @@ function GameCenterPage({ />
-
- {awardSummary.awards.map((award) => ( - - ))} -
+
); @@ -752,7 +856,7 @@ function GameCenterPage({
-
+
-
- - - -
+ ) : ( + showAchievements && ( +
+ + Personal highlight + + + {achievementSummary.unlockedCount}/ + {achievementSummary.totalCount} + + + achievements unlocked so far + +
+ ) + )}
@@ -921,74 +1037,6 @@ function GameCenterPage({ )}
- -
- {questDashboard ? ( - <> -
- -
- - Current level - - - Level {questDashboard.level.level} - -
-
-
-
- - XP to next level - - - {questDashboard.level.xpToNextLevel.toLocaleString()} - -
- -
- - ) : ( - showAchievements && ( - <> - - Personal highlight - - - {achievementSummary.unlockedCount}/ - {achievementSummary.totalCount} - - - achievements unlocked so far - - - ) - )} -
From 9ac28574f5c2d96ea7228900773c61c76484d4aa Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 12:09:29 +0300 Subject: [PATCH 02/67] feat(game-center): trophy case award grid ordered by rarity Replace the flat award grid with a trophy grid that surfaces each award's real art, sized uniformly and ordered rarest-first (by the award's Cores value, joined from the products catalog). Co-Authored-By: Claude Opus 4.8 --- .../components/game-center/TrophyGrid.tsx | 57 ++++++ .../components/game-center/TrophyShelf.tsx | 95 ---------- packages/webapp/lib/gameCenter.spec.ts | 15 +- packages/webapp/lib/gameCenter.ts | 71 +------- packages/webapp/pages/game-center/index.tsx | 164 +----------------- 5 files changed, 74 insertions(+), 328 deletions(-) create mode 100644 packages/webapp/components/game-center/TrophyGrid.tsx delete mode 100644 packages/webapp/components/game-center/TrophyShelf.tsx diff --git a/packages/webapp/components/game-center/TrophyGrid.tsx b/packages/webapp/components/game-center/TrophyGrid.tsx new file mode 100644 index 00000000000..25fd81f6b3d --- /dev/null +++ b/packages/webapp/components/game-center/TrophyGrid.tsx @@ -0,0 +1,57 @@ +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/components/game-center/TrophyShelf.tsx b/packages/webapp/components/game-center/TrophyShelf.tsx deleted file mode 100644 index c2f1c34f640..00000000000 --- a/packages/webapp/components/game-center/TrophyShelf.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { CSSProperties, 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, - TypographyType, -} from '@dailydotdev/shared/src/components/typography/Typography'; -import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; -import type { TrophyShelfItem } from '../../lib/gameCenter'; - -type TrophyProps = { - item: TrophyShelfItem; -}; - -const Trophy = ({ item }: TrophyProps): ReactElement => { - const { name, image, count, size, imageGlow } = item; - const glowStyle: CSSProperties | undefined = imageGlow - ? { - background: `radial-gradient(closest-side, ${imageGlow}, transparent 72%)`, - } - : undefined; - - return ( - -
-
- {glowStyle && ( - - )} - {name} - - - ×{count.toLocaleString()} - - -
- - {name} - -
-
- ); -}; - -type TrophyShelfProps = { - shelves: TrophyShelfItem[][]; -}; - -export const TrophyShelf = ({ shelves }: TrophyShelfProps): ReactElement => { - return ( -
- {shelves.map((row) => ( -
item.id).join('-')} - className="flex flex-col" - > -
- {row.map((item) => ( - - ))} -
-
- -
-
- ))} -
- ); -}; diff --git a/packages/webapp/lib/gameCenter.spec.ts b/packages/webapp/lib/gameCenter.spec.ts index ada862c5506..a7c25eb9840 100644 --- a/packages/webapp/lib/gameCenter.spec.ts +++ b/packages/webapp/lib/gameCenter.spec.ts @@ -21,7 +21,6 @@ import { getMostProgressedQuest, getQuestSummary, getTopReaderTopicLabel, - getTrophyShelves, } from './gameCenter'; const createQuest = ( @@ -353,7 +352,7 @@ describe('game center helpers', () => { ]); }); - it('sizes trophies by rarity (award value), rarest first and largest', () => { + 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 }, @@ -365,17 +364,13 @@ describe('game center helpers', () => { { id: 'c', value: 100 }, ] as Product[]; - const { shelves } = getAwardSummary(awards, catalog); - const flat = shelves.flat(); + const { awardsByRarity } = getAwardSummary(awards, catalog); // rarest (highest value) first, regardless of how many were earned - expect(flat.map((item) => item.id)).toEqual(['b', 'c', 'a']); - // and it renders bigger the rarer it is - expect(flat[0].size).toBeGreaterThan(flat[1].size); - expect(flat[1].size).toBeGreaterThan(flat[2].size); + expect(awardsByRarity.map((award) => award.id)).toEqual(['b', 'c', 'a']); }); - it('returns no shelves when there are no awards', () => { - expect(getTrophyShelves([])).toEqual([]); + 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 473636bcb2d..ce50b1bc8a9 100644 --- a/packages/webapp/lib/gameCenter.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -283,24 +283,15 @@ export type AwardWithRarity = UserProductSummary & { imageGlow?: string | null; }; -export type TrophyShelfItem = AwardWithRarity & { size: number }; - export type GameCenterAwardSummary = { awards: UserProductSummary[]; - shelves: TrophyShelfItem[][]; + // Awards ordered rarest-first for the trophy grid. + awardsByRarity: AwardWithRarity[]; totalAwards: number; uniqueAwards: number; favoriteAward: UserProductSummary | null; }; -// Trophies scale with rarity (an award's Cores value). Sizes are in px and are -// consumed as inline width so the shelf can render server-side without JS. -const TROPHY_SIZE_MIN = 64; -const TROPHY_SIZE_MAX = 148; -// Width a single shelf row tries to fill before wrapping to the next plank. -const SHELF_ROW_BUDGET = 540; -const TROPHY_GAP = 32; - const enrichAwardsWithRarity = ( awards: UserProductSummary[], products: Product[], @@ -316,28 +307,12 @@ const enrichAwardsWithRarity = ( }); }; -const getTrophySize = (value: number, min: number, max: number): number => { - if (max <= min) { - return Math.round((TROPHY_SIZE_MIN + TROPHY_SIZE_MAX) / 2); - } - // sqrt easing keeps the cheapest awards from collapsing to nothing while the - // rarest still tower over them. - const ratio = Math.sqrt((value - min) / (max - min)); - return Math.round( - TROPHY_SIZE_MIN + ratio * (TROPHY_SIZE_MAX - TROPHY_SIZE_MIN), - ); -}; - -// Rarest-first, packed into shelf rows by width so the big trophies get their -// own roomy plank up top and the commons cluster below. -export const getTrophyShelves = ( +// 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[], -): TrophyShelfItem[][] => { - if (awards.length === 0) { - return []; - } - - const sorted = [...awards].sort((left, right) => { +): AwardWithRarity[] => { + return [...awards].sort((left, right) => { if (left.value !== right.value) { return right.value - left.value; } @@ -346,34 +321,6 @@ export const getTrophyShelves = ( } return left.name.localeCompare(right.name); }); - - const values = sorted.map((award) => award.value); - const min = Math.min(...values); - const max = Math.max(...values); - - const items: TrophyShelfItem[] = sorted.map((award) => ({ - ...award, - size: getTrophySize(award.value, min, max), - })); - - const shelves: TrophyShelfItem[][] = []; - let row: TrophyShelfItem[] = []; - let rowWidth = 0; - items.forEach((item) => { - const itemWidth = item.size + TROPHY_GAP; - if (row.length > 0 && rowWidth + itemWidth > SHELF_ROW_BUDGET) { - shelves.push(row); - row = []; - rowWidth = 0; - } - row.push(item); - rowWidth += itemWidth; - }); - if (row.length > 0) { - shelves.push(row); - } - - return shelves; }; export const getAwardSummary = ( @@ -381,13 +328,13 @@ export const getAwardSummary = ( products?: Product[], ): GameCenterAwardSummary => { const allAwards = sortAwardsByCount(awards ?? []); - const shelves = getTrophyShelves( + const awardsByRarity = sortAwardsByRarity( enrichAwardsWithRarity(allAwards, products ?? []), ); return { awards: allAwards, - shelves, + awardsByRarity, totalAwards: allAwards.reduce((total, award) => total + 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 8899b4444bc..4bddec22284 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -71,6 +71,7 @@ import { import { AchievementCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementCard'; import { TopReaderBadge } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; import { getQuestLevelProgress } from '@dailydotdev/shared/src/components/quest/QuestLevelProgressCircle'; +import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; import { QuestSection } from '@dailydotdev/shared/src/components/quest/QuestButton'; import type { QuestDestination } from '@dailydotdev/shared/src/components/quest/QuestButton'; import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; @@ -79,16 +80,13 @@ import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ArrowIcon, CoreIcon, - HotIcon, MedalBadgeIcon, PinIcon, - ReputationLightningIcon, - StarIcon, } from '@dailydotdev/shared/src/components/icons'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; -import { TrophyShelf } from '../../components/game-center/TrophyShelf'; +import { TrophyGrid } from '../../components/game-center/TrophyGrid'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; import { @@ -177,160 +175,6 @@ const EmptyStateCard = ({ ); }; -const xpSegmentCount = 10; - -const HudStatTile = ({ - icon, - label, - value, -}: { - icon: ReactElement; - label: string; - value: string; -}): ReactElement => ( -
-
- {icon} - - {label} - -
- - {value} - -
-); - -const LevelHud = ({ - level, - levelProgress, - totalXp, - xpToNextLevel, - currentStreak, - longestStreak, - achievements, - isPending, -}: { - level: number; - levelProgress: number; - totalXp: number; - xpToNextLevel: number; - currentStreak: number; - longestStreak: number; - achievements?: { unlocked: number; total: number }; - isPending: boolean; -}): ReactElement => { - const filledSegments = Math.round((levelProgress / 100) * xpSegmentCount); - const streakValue = isPending ? '...' : `${currentStreak.toLocaleString()}d`; - const longestValue = isPending ? '...' : `${longestStreak.toLocaleString()}d`; - - return ( -
-
-
-
-
- - LVL - - - {level} - -
- - {xpToNextLevel.toLocaleString()} XP to level {level + 1} - -
-
-
- - - {totalXp.toLocaleString()} - -
- - total XP - -
-
-
- {Array.from({ length: xpSegmentCount }, (_, index) => ( - - ))} -
-
-
- - } - label="Streak" - value={streakValue} - /> - - } - label="Longest" - value={longestValue} - /> - {achievements ? ( - - } - label="Badges" - value={`${achievements.unlocked}/${achievements.total}`} - /> - ) : null} -
-
- ); -}; - const seoTitles = getPageSeoTitles('Game Center'); const seo: NextSeoProps = { title: seoTitles.title, @@ -818,9 +662,7 @@ function GameCenterPage({ } />
-
- -
+ ); } else { From 9104409070b467df28300dbaa97a40ae5adfe665 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 12:10:42 +0300 Subject: [PATCH 03/67] feat(game-center): milestone quests redesign mockup Vertical stack of horizontal milestone cards with production icons, action-matched badge colours, claim-first ordering, right-aligned shining claim button. Co-Authored-By: Claude Opus 4.8 --- milestone-quests-badge-top.html | 214 ++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 milestone-quests-badge-top.html diff --git a/milestone-quests-badge-top.html b/milestone-quests-badge-top.html new file mode 100644 index 00000000000..02aaf65113b --- /dev/null +++ b/milestone-quests-badge-top.html @@ -0,0 +1,214 @@ +Milestone Quests — Badge Top (narrow) + + +
+
+

Milestone quests — Badge Left, vertical stack

+

Horizontal boxes stacked vertically, using the real daily.dev icons — ReadingStreak, Upvote, Discuss, Eye, Star, Bookmark for the milestone topics, and Core / Reputation on the reward chips. Badge colour matches each action's product colour — streak pink/red, upvote green, others our purple. Claim button vertically centered on the far right.

+
+ +
+
+ 01 + Badge Left · vertical stack + Ordering rule: ready-to-claim jumps to the top, then in-progress by progress (closest to done first), and claimed cards settle at the bottom. +
+ +
+ 1 · Ready to claim → top + 2 · In progress → by progress, closest first + 3 · Claimed → bottom +
+ +
+ +
+
+
+
Maintain a 7-day streak
+
You did it — claim before it resets.
+
+1,000xp+250
+
+
+
+ +
+
+
+
Upvote 200 posts
+
Milestone complete — reward waiting.
+
+2,000+100
+
+
+
+ + +
+
+
+
Comment on 25 posts
+
Share your take across the feed.
+
+500xp+120
+
18/25In progress
+
+
+ +
+
+
+
Read 100 posts
+
Keep reading to reach the milestone.
+
+500+50
+
65/100In progress
+
+
+ +
+
+
+
Reach level 10
+
Unlock with daily.dev Plus.
+
+5,000
+
4/10Plus req.
+
+
+ + +
+
+
+
First 10 bookmarks
+
Goal reached and reward collected.
+
+250
+
10/10Claimed
+
+
CLAIMED
+
+
+
+ +

Same data as today — quest name, description, progress (value/target + %), status label, reward chips (Cores / Reputation / XP, XP hidden when the level system is off), claim button, plus lock & claimed states.

+
From c85cd65fd4d990d9004c878b14bc053bafea3263 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 12:13:54 +0300 Subject: [PATCH 04/67] feat(game-center): redesign achievement shelf as netflix-style slab cards Replace the 3-column achievement grid on the Game Center shelf with a horizontal row of full-bleed cards where the reward image fills the card. Each card carries the name, description, progress or unlock date, a rarity glow ring and pill, and an XS track/tracked control. Clicking a card opens a centered modal with the enlarged square image and compact details. Scoped to the shelf via a new AchievementShelfCard; the shared AchievementCard used by other profile surfaces is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../achievements/AchievementShelfCard.tsx | 264 ++++++++++++++++++ packages/webapp/pages/game-center/index.tsx | 6 +- 2 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx 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..2aa07e22ab1 --- /dev/null +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -0,0 +1,264 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { UserAchievement } from '../../../../graphql/user/achievements'; +import { + AchievementType, + getTargetCount, +} from '../../../../graphql/user/achievements'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; +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 { PinIcon } from '../../../../components/icons'; +import { + AchievementRarityTier, + getAchievementRarityTier, + rarityGlowClasses, +} 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'; + +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 showProgress = + achievement.type === AchievementType.Milestone && !isUnlocked; + const rarityTier = isUnlocked + ? getAchievementRarityTier(achievement.rarity) + : null; + const rarityLabel = + rarityTier === AchievementRarityTier.Emerald + ? '<1%' + : `${Math.round(achievement.rarity ?? 0)}%`; + const canTrack = !isUnlocked && isOwner && !!onTrack; + + return ( + <> + + ) : ( + + )} + + )} + +
+ + {achievement.name} + + + {achievement.description} + + {showProgress ? ( +
+ + {progress}/{targetCount} + + +
+ ) : ( + isUnlocked && + unlockedAt && ( + + Unlocked{' '} + {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + + ) + )} +
+ + + {isExpanded && ( + setIsExpanded(false)} + kind={ModalKind.FlexibleCenter} + size={ModalSize.XSmall} + > +
+
+ {achievement.name} + {rarityTier && ( + + {rarityLabel} rare + + )} + setIsExpanded(false)} + /> +
+
+ + {achievement.name} + + + {achievement.description} + + {showProgress ? ( +
+ + {progress}/{targetCount} + + +
+ ) : ( + isUnlocked && + unlockedAt && ( + + Unlocked{' '} + {formatDate({ + value: unlockedAt, + type: TimeFormatType.Post, + })} + + ) + )} +
+
+
+ )} + + ); +} diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 4bddec22284..562bc2081d9 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -68,7 +68,7 @@ import { ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { AchievementCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementCard'; +import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; import { TopReaderBadge } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; import { getQuestLevelProgress } from '@dailydotdev/shared/src/components/quest/QuestLevelProgressCircle'; import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; @@ -442,9 +442,9 @@ function GameCenterPage({ ); } else if (featuredAchievements.length > 0) { achievementShelfContent = ( -
+
{featuredAchievements.map((achievement) => ( - Date: Sun, 23 Aug 2026 12:30:09 +0300 Subject: [PATCH 05/67] feat(game-center): add shared LevelHud component (dark cabbage banner) + story Adds the shared LevelHud used by the game-center progress snapshot with the deep-cabbage banner treatment: bright cabbage level badge, light-cabbage segmented XP bar, and streak/longest/badges tiles. Includes a Storybook story with variants. Resolves the dangling shared import already referenced by the game-center page. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/quest/LevelHud.tsx | 171 ++++++++++++++++++ .../stories/components/LevelHud.stories.tsx | 67 +++++++ 2 files changed, 238 insertions(+) create mode 100644 packages/shared/src/components/quest/LevelHud.tsx create mode 100644 packages/storybook/stories/components/LevelHud.stories.tsx diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx new file mode 100644 index 00000000000..e21785931c3 --- /dev/null +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -0,0 +1,171 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { IconSize } from '../Icon'; +import { + HotIcon, + MedalBadgeIcon, + ReputationLightningIcon, + StarIcon, +} from '../icons'; + +const xpSegmentCount = 10; + +const HudStatTile = ({ + icon, + label, + value, +}: { + icon: ReactElement; + label: string; + value: string; +}): ReactElement => ( +
+
+ {icon} + + {label} + +
+ + {value} + +
+); + +export interface LevelHudProps { + level: number; + levelProgress: number; + totalXp: number; + xpToNextLevel: number; + currentStreak: number; + longestStreak: number; + achievements?: { unlocked: number; total: number }; + isPending: boolean; +} + +export const LevelHud = ({ + level, + levelProgress, + totalXp, + xpToNextLevel, + currentStreak, + longestStreak, + achievements, + isPending, +}: LevelHudProps): ReactElement => { + const filledSegments = Math.round((levelProgress / 100) * xpSegmentCount); + const streakValue = isPending ? '...' : `${currentStreak.toLocaleString()}d`; + const longestValue = isPending ? '...' : `${longestStreak.toLocaleString()}d`; + + return ( +
+
+
+
+
+ + LVL + + + {level} + +
+ + {xpToNextLevel.toLocaleString()} XP to level {level + 1} + +
+
+
+ + + {totalXp.toLocaleString()} + +
+ + total XP + +
+
+
+ {Array.from({ length: xpSegmentCount }, (_, index) => ( + + ))} +
+
+
+ + } + label="Streak" + value={streakValue} + /> + + } + label="Longest" + value={longestValue} + /> + {achievements ? ( + + } + label="Badges" + value={`${achievements.unlocked}/${achievements.total}`} + /> + ) : null} +
+
+ ); +}; diff --git a/packages/storybook/stories/components/LevelHud.stories.tsx b/packages/storybook/stories/components/LevelHud.stories.tsx new file mode 100644 index 00000000000..d465a98f451 --- /dev/null +++ b/packages/storybook/stories/components/LevelHud.stories.tsx @@ -0,0 +1,67 @@ +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: { + level: 14, + levelProgress: 70, + totalXp: 3420, + xpToNextLevel: 580, + currentStreak: 12, + longestStreak: 28, + 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, + xpToNextLevel: 460, + currentStreak: 0, + longestStreak: 0, + achievements: { unlocked: 0, total: 24 }, + }, +}; + +export const MaxedOutStreak: Story = { + args: { + level: 42, + levelProgress: 95, + totalXp: 128400, + 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, + }, +}; From 1efd7d4ffc8009ff77edeaa8fe3f3a39a818e585 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 12:52:55 +0300 Subject: [PATCH 06/67] feat(game-center): ship the milestone quests and badge case redesigns Milestone quests move from the two-column QuestCard grid to a vertical stack of horizontal cards: an action-coloured badge on the left (streak pink, upvote green, everything else cabbage), name/description/reward chips in the middle, and a shining Claim button pinned right. Ordering now puts ready-to-claim on top, then in-progress closest-to-target, with claimed milestones settling at the bottom under a CLAIMED stamp. This replaces milestone-quests-badge-top.html, the static mockup the design was prototyped in, so the file goes away. The badge case drops the three DataTiles and renders every top-reader badge as a compact card in a horizontal scroller instead of the first three full-size TopReaderBadge cards. Co-Authored-By: Claude Opus 5 --- milestone-quests-badge-top.html | 214 ------------- .../badges/TopReaderBadgeCompact.tsx | 51 +++ packages/shared/src/styles/base.css | 44 +++ .../TopReaderBadgeCompact.stories.tsx | 40 +++ .../__tests__/GameCenterStaticProps.spec.ts | 32 +- .../game-center/MilestoneQuestList.tsx | 303 ++++++++++++++++++ packages/webapp/lib/gameCenter.spec.ts | 44 +++ packages/webapp/lib/gameCenter.ts | 36 +++ packages/webapp/pages/game-center/index.tsx | 142 +------- 9 files changed, 557 insertions(+), 349 deletions(-) delete mode 100644 milestone-quests-badge-top.html create mode 100644 packages/shared/src/components/badges/TopReaderBadgeCompact.tsx create mode 100644 packages/storybook/stories/components/TopReaderBadgeCompact.stories.tsx create mode 100644 packages/webapp/components/game-center/MilestoneQuestList.tsx diff --git a/milestone-quests-badge-top.html b/milestone-quests-badge-top.html deleted file mode 100644 index 02aaf65113b..00000000000 --- a/milestone-quests-badge-top.html +++ /dev/null @@ -1,214 +0,0 @@ -Milestone Quests — Badge Top (narrow) - - -
-
-

Milestone quests — Badge Left, vertical stack

-

Horizontal boxes stacked vertically, using the real daily.dev icons — ReadingStreak, Upvote, Discuss, Eye, Star, Bookmark for the milestone topics, and Core / Reputation on the reward chips. Badge colour matches each action's product colour — streak pink/red, upvote green, others our purple. Claim button vertically centered on the far right.

-
- -
-
- 01 - Badge Left · vertical stack - Ordering rule: ready-to-claim jumps to the top, then in-progress by progress (closest to done first), and claimed cards settle at the bottom. -
- -
- 1 · Ready to claim → top - 2 · In progress → by progress, closest first - 3 · Claimed → bottom -
- -
- -
-
-
-
Maintain a 7-day streak
-
You did it — claim before it resets.
-
+1,000xp+250
-
-
-
- -
-
-
-
Upvote 200 posts
-
Milestone complete — reward waiting.
-
+2,000+100
-
-
-
- - -
-
-
-
Comment on 25 posts
-
Share your take across the feed.
-
+500xp+120
-
18/25In progress
-
-
- -
-
-
-
Read 100 posts
-
Keep reading to reach the milestone.
-
+500+50
-
65/100In progress
-
-
- -
-
-
-
Reach level 10
-
Unlock with daily.dev Plus.
-
+5,000
-
4/10Plus req.
-
-
- - -
-
-
-
First 10 bookmarks
-
Goal reached and reward collected.
-
+250
-
10/10Claimed
-
-
CLAIMED
-
-
-
- -

Same data as today — quest name, description, progress (value/target + %), status label, reward chips (Cores / Reputation / XP, XP hidden when the level system is off), claim button, plus lock & claimed states.

-
diff --git a/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx b/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx new file mode 100644 index 00000000000..53cd148b23a --- /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/styles/base.css b/packages/shared/src/styles/base.css index beb4d42a9e0..68e4f8c60bf 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1075,6 +1075,50 @@ 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; + } + } + @keyframes float { 0%, 100% { transform: translateY(0); 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/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index dee394ed971..96c6825df95 100644 --- a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -692,7 +692,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 +746,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/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx new file mode 100644 index 00000000000..53d16266652 --- /dev/null +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -0,0 +1,303 @@ +import type { ComponentType, 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 type { IconProps } from '@dailydotdev/shared/src/components/Icon'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + BookmarkIcon, + CoreIcon, + DiscussIcon, + EyeIcon, + ReadingStreakIcon, + ReputationIcon, + StarIcon, + UpvoteIcon, +} 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'; + +// The badge takes the colour of the action it tracks, not of the claim state, +// so a milestone is recognisable before its title is read. +enum MilestoneAccent { + Streak = 'streak', + Upvote = 'upvote', + Default = 'default', +} + +const accentClasses: Record = { + [MilestoneAccent.Streak]: + 'border-accent-bacon-default/50 bg-accent-bacon-default/[0.12] text-accent-bacon-default', + [MilestoneAccent.Upvote]: + 'border-accent-avocado-default/50 bg-accent-avocado-default/[0.12] text-accent-avocado-default', + [MilestoneAccent.Default]: + 'border-accent-cabbage-default/50 bg-accent-cabbage-default/[0.12] text-accent-cabbage-default', +}; + +type MilestoneVisual = { + Icon: ComponentType; + accent: MilestoneAccent; +}; + +const eventVisuals: Record = { + read_post: { Icon: EyeIcon, accent: MilestoneAccent.Default }, + brief_read: { Icon: EyeIcon, accent: MilestoneAccent.Default }, + visit_explore_page: { Icon: EyeIcon, accent: MilestoneAccent.Default }, + post_upvote: { Icon: UpvoteIcon, accent: MilestoneAccent.Upvote }, + comment_upvote: { Icon: UpvoteIcon, accent: MilestoneAccent.Upvote }, + comment_create: { Icon: DiscussIcon, accent: MilestoneAccent.Default }, + hot_take_create: { Icon: DiscussIcon, accent: MilestoneAccent.Default }, + hot_take_vote: { Icon: DiscussIcon, accent: MilestoneAccent.Default }, + visit_discussions_page: { + Icon: DiscussIcon, + accent: MilestoneAccent.Default, + }, + bookmark_post: { Icon: BookmarkIcon, accent: MilestoneAccent.Default }, + visit_read_it_later_page: { + Icon: BookmarkIcon, + accent: MilestoneAccent.Default, + }, +}; + +const fallbackVisual: MilestoneVisual = { + Icon: StarIcon, + accent: MilestoneAccent.Default, +}; + +const streakVisual: MilestoneVisual = { + Icon: ReadingStreakIcon, + accent: MilestoneAccent.Streak, +}; + +// Streak milestones are named by the server, so match on the event family +// rather than enumerating every `*_streak` variant it may add. +const getMilestoneVisual = (eventType: string): MilestoneVisual => { + if (eventType.includes('streak')) { + return streakVisual; + } + + return eventVisuals[eventType] ?? fallbackVisual; +}; + +const rewardChipClasses: Record = { + [QuestRewardType.Cores]: 'text-accent-cheese-default', + [QuestRewardType.Reputation]: 'text-accent-onion-default', + [QuestRewardType.Xp]: 'text-accent-avocado-default', +}; + +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 { Icon, accent } = getMilestoneVisual(quest.quest.eventType); + 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) => ( + + ))} +
+ )} + + {!canClaim && ( +
+ +
+ + {value}/{target} + + + {statusLabel} + +
+
+ )} +
+ + {canClaim && ( + + )} + + {isClaimed && ( + + + Claimed + + + )} +
+ ); +}; + +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/lib/gameCenter.spec.ts b/packages/webapp/lib/gameCenter.spec.ts index a7c25eb9840..d6913094c4c 100644 --- a/packages/webapp/lib/gameCenter.spec.ts +++ b/packages/webapp/lib/gameCenter.spec.ts @@ -21,6 +21,7 @@ import { getMostProgressedQuest, getQuestSummary, getTopReaderTopicLabel, + sortMilestoneQuests, } from './gameCenter'; const createQuest = ( @@ -212,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', diff --git a/packages/webapp/lib/gameCenter.ts b/packages/webapp/lib/gameCenter.ts index ce50b1bc8a9..bd8a64e9879 100644 --- a/packages/webapp/lib/gameCenter.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -133,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 => { diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 562bc2081d9..b68dc8f5db2 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -30,10 +30,6 @@ 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'; @@ -69,11 +65,9 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; -import { TopReaderBadge } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; +import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; import { getQuestLevelProgress } from '@dailydotdev/shared/src/components/quest/QuestLevelProgressCircle'; import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; -import { QuestSection } from '@dailydotdev/shared/src/components/quest/QuestButton'; -import type { QuestDestination } from '@dailydotdev/shared/src/components/quest/QuestButton'; 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'; @@ -86,15 +80,14 @@ import { import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; +import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; import { TrophyGrid } from '../../components/game-center/TrophyGrid'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; import { getAchievementSummary, getAwardSummary, - getBadgeSummary, getMostProgressedQuest, - getTopReaderTopicLabel, } from '../../lib/gameCenter'; type GameCenterPageProps = { @@ -256,7 +249,6 @@ function GameCenterPage({ const claimingMilestoneQuestId = isClaimQuestPending ? claimQuestVariables?.userQuestId : undefined; - const emptyQuestAnimationState = useMemo(() => new Set(), []); const topReaderQueryKey = generateQueryKey( RequestKey.TopReaderBadge, @@ -275,14 +267,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, @@ -321,15 +306,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 && @@ -358,20 +334,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({ @@ -408,17 +370,10 @@ function GameCenterPage({ ); } else if (milestoneQuests.length > 0) { milestoneQuestContent = ( - ); @@ -488,85 +443,18 @@ 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'} - - } - /> - +
+ {topReaderBadges.map((badge) => ( +
+ - } - subtitle={ - - breadth of expertise - - } - /> - - {mostEarnedBadgeSubtitle} - - } - /> -
-
-
- {badgeCaseBadges.map((badge) => ( -
- -
- ))} -
+
+ ))}
- +
); } else { badgeCaseContent = ( @@ -1051,7 +939,7 @@ function GameCenterPage({
{badgeCaseContent} From 80c17fc6f9dfc03ec1eb84d3e825014038648e43 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 14:19:43 +0300 Subject: [PATCH 07/67] fix(game-center): make the redesigned sections render in both themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the five sections in Storybook surfaced three rendering bugs that only a real render shows. Alpha modifiers on the accent/background tokens silently produce nothing: those tokens are bare `var(--theme-*)` strings, so Tailwind cannot inject an alpha channel and the utility is dropped. `bg-accent-cabbage-default/10` and `bg-background-default/70` therefore rendered fully transparent, and `text-white/70` fell back to the inherited near-black — invisible on the LevelHud banner in light mode. Swapped for the overlay palette, which carries real 8-digit hex values, and for solid tokens where the translucency was decorative. The milestone badge glyphs for streak and level used each icon's primary variant, which is an outline and read as an empty circle at badge size; they now use the filled secondary variant. Milestone reward chips coloured the whole chip with the reward accent, leaving Cores as pale cheese-on-white in light mode. Only the glyph is coloured now, matching the existing QuestCard chips. Adds a Storybook page composing all five redesigned sections with mock data, so the redesign is reviewable without an authenticated session. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 4 +- .../pages/GameCenterRedesign.stories.tsx | 372 ++++++++++++++++++ .../game-center/MilestoneQuestList.tsx | 45 ++- packages/webapp/pages/game-center/index.tsx | 10 +- 4 files changed, 404 insertions(+), 27 deletions(-) create mode 100644 packages/storybook/stories/pages/GameCenterRedesign.stories.tsx diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index e21785931c3..f4b73cda1ee 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -25,7 +25,7 @@ const HudStatTile = ({ label: string; value: string; }): ReactElement => ( -
+
{icon} total XP diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx new file mode 100644 index 00000000000..3da527f5654 --- /dev/null +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -0,0 +1,372 @@ +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 { 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 { Divider } from '@dailydotdev/shared/src/components/utilities'; +import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; +import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; +import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; + +const SectionHeader = ({ + title, + description, +}: { + title: string; + description: string; +}) => ( +
+ + {title} + + + {description} + +
+); + +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 }, + ], + }, + ), +]; + +const achievement = ( + id: string, + name: string, + description: string, + progress: number, + targetCount: number, + rarity: number | null, + unlockedAt: string | null, +): UserAchievement => ({ + achievement: { + id, + name, + description, + image: `https://media.daily.dev/image/upload/s--placeholder--/f_auto/v1/achievements/${id}`, + type: AchievementType.Milestone, + criteria: { targetCount }, + points: 100, + rarity, + unit: 'posts', + }, + progress, + unlockedAt, + createdAt: null, + updatedAt: null, +}); + +const achievements: UserAchievement[] = [ + achievement( + 'night-owl', + 'Night Owl', + 'Read 50 posts after midnight.', + 32, + 50, + null, + null, + ), + achievement( + 'deep-diver', + 'Deep Diver', + 'Finish 100 long reads.', + 100, + 100, + 0.4, + '2026-08-10T00:00:00.000Z', + ), + achievement( + 'first-light', + 'First Light', + 'Read on 30 consecutive mornings.', + 30, + 30, + 12, + '2026-07-02T00:00:00.000Z', + ), + achievement( + 'tastemaker', + 'Tastemaker', + 'Have 250 upvotes on your comments.', + 180, + 250, + null, + null, + ), +]; + +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 dividerClass = 'bg-border-subtlest-tertiary'; + +const GameCenterRedesign = () => ( +
+
+
+
+
+
+
+ + Progress snapshot + + + Tomer, here's how you're doing. + + +
+
+ + + +
+ + undefined} + /> +
+ + + +
+ +
+ {achievements.map((item) => ( + + ))} +
+
+ + + +
+ +
+
+ {badges.map((badge) => ( +
+ +
+ ))} +
+
+
+ + + +
+ + +
+
+); + +const queryClient = new QueryClient(); + +const meta: Meta = { + title: 'Pages/Game Center Redesign', + component: GameCenterRedesign, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const AllSections: Story = {}; diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index 53d16266652..16dd80e77b5 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -49,18 +49,23 @@ enum MilestoneAccent { Default = 'default', } +// Tinted fills come from the overlay palette: an alpha modifier on an +// accent token (`/12`) resolves to a bare `var()` and renders transparent. const accentClasses: Record = { [MilestoneAccent.Streak]: - 'border-accent-bacon-default/50 bg-accent-bacon-default/[0.12] text-accent-bacon-default', + 'border-accent-bacon-default bg-overlay-active-bacon text-accent-bacon-default', [MilestoneAccent.Upvote]: - 'border-accent-avocado-default/50 bg-accent-avocado-default/[0.12] text-accent-avocado-default', + 'border-accent-avocado-default bg-overlay-active-avocado text-accent-avocado-default', [MilestoneAccent.Default]: - 'border-accent-cabbage-default/50 bg-accent-cabbage-default/[0.12] text-accent-cabbage-default', + 'border-accent-cabbage-default bg-overlay-active-cabbage text-accent-cabbage-default', }; type MilestoneVisual = { Icon: ComponentType; accent: MilestoneAccent; + // Some glyphs only get their solid fill from the secondary variant; the + // outline reads as an empty circle at badge size. + secondary?: boolean; }; const eventVisuals: Record = { @@ -86,11 +91,13 @@ const eventVisuals: Record = { const fallbackVisual: MilestoneVisual = { Icon: StarIcon, accent: MilestoneAccent.Default, + secondary: true, }; const streakVisual: MilestoneVisual = { Icon: ReadingStreakIcon, accent: MilestoneAccent.Streak, + secondary: true, }; // Streak milestones are named by the server, so match on the event family @@ -103,35 +110,33 @@ const getMilestoneVisual = (eventType: string): MilestoneVisual => { return eventVisuals[eventType] ?? fallbackVisual; }; -const rewardChipClasses: Record = { - [QuestRewardType.Cores]: 'text-accent-cheese-default', - [QuestRewardType.Reputation]: 'text-accent-onion-default', - [QuestRewardType.Xp]: 'text-accent-avocado-default', -}; - +// Only the glyph carries the reward's colour — the amount inherits the text +// token so the chip stays legible on the light surface too. const RewardChipIcon = ({ type }: { type: QuestRewardType }): ReactElement => { if (type === QuestRewardType.Cores) { - return ; + return ( + + ); } if (type === QuestRewardType.Reputation) { - return ; + return ( + + ); } return ( - + xp ); }; const RewardChip = ({ reward }: { reward: QuestReward }): ReactElement => ( - + +{reward.amount.toLocaleString()} ); @@ -149,7 +154,7 @@ const MilestoneQuestCard = ({ isClaiming, onClaim, }: MilestoneQuestCardProps): ReactElement => { - const { Icon, accent } = getMilestoneVisual(quest.quest.eventType); + const { Icon, accent, secondary } = getMilestoneVisual(quest.quest.eventType); 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)); @@ -172,7 +177,7 @@ const MilestoneQuestCard = ({ )} aria-hidden > - +
diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index b68dc8f5db2..95f011a93d6 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -583,8 +583,8 @@ function GameCenterPage({
-
-
+
+
@@ -634,7 +634,7 @@ function GameCenterPage({ /> ) : ( showAchievements && ( -
+
-
+
{showAchievements && ( -
+
Date: Sun, 23 Aug 2026 14:57:42 +0300 Subject: [PATCH 08/67] feat(game-center): vertical milestone cards in a scroller, frameless trophies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone quests go back to the earlier arrangement: each quest is a vertical 240px card — badge, name, description, reward chips stacked — and the set scrolls horizontally instead of stacking down the page. The claim button and the progress bar sit on a shared baseline via mt-auto so neighbouring cards line up despite different description lengths. Trophy cells lose their card frame: no border, background, or radius, so the award art carries the grid on its own. The Storybook page's achievement mocks pointed at fabricated image URLs that 404, which is why the shelf rendered blank; they now use a real media.daily.dev asset. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 3 +- .../game-center/MilestoneQuestList.tsx | 96 +++++++++---------- .../components/game-center/TrophyGrid.tsx | 2 +- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 3da527f5654..5706e7a388b 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -19,6 +19,7 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { Divider } from '@dailydotdev/shared/src/components/utilities'; +import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; @@ -172,7 +173,7 @@ const achievement = ( id, name, description, - image: `https://media.daily.dev/image/upload/s--placeholder--/f_auto/v1/achievements/${id}`, + image: featuredAwardImage, type: AchievementType.Milestone, criteria: { targetCount }, points: 100, diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index 16dd80e77b5..2a3f8f28abd 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -166,13 +166,13 @@ const MilestoneQuestCard = ({ return (
-
- - {quest.quest.name} - - - {quest.quest.description} - + + {quest.quest.name} + + + {quest.quest.description} + - {visibleRewards.length > 0 && ( -
- {visibleRewards.map((reward, index) => ( - - ))} -
- )} + {visibleRewards.length > 0 && ( +
+ {visibleRewards.map((reward, index) => ( + + ))} +
+ )} - {!canClaim && ( -
+ {/* Pushes the claim button and progress bar to a shared baseline so + neighbouring cards in the scroller line up. */} +
+ {canClaim ? ( + + ) : ( + <>
-
+ )}
- {canClaim && ( - - )} - {isClaimed && ( +
{ordered.map((quest) => ( {
Date: Sun, 23 Aug 2026 15:15:34 +0300 Subject: [PATCH 09/67] fix(game-center): tighten the trophy grid and use real achievement art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trophy cells kept 16px of padding after losing their frame, and the grid topped out at six columns, so the art floated in a lot of dead space. Cells drop their horizontal padding and the grid goes to 4/6/8 columns, which halves the section's height for a typical collection. The Storybook page's achievements were four invented entries sharing one placeholder image. They are now six real entries pulled from the achievements catalogue — varied artwork, real rarity spread, and a mix of locked and unlocked so the greyscale and glow-ring treatments both show. Storybook's Tailwind content globs did not cover packages/webapp, so classes used only by the webapp-only game-center components were never generated: the trophy grid silently rendered at the wrong column count in the preview. Added the webapp components path. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 82 ++++++++++++------- packages/storybook/tailwind.config.ts | 3 + .../components/game-center/TrophyGrid.tsx | 6 +- 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 5706e7a388b..f5541b1d03b 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -19,7 +19,6 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { Divider } from '@dailydotdev/shared/src/components/utilities'; -import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image'; import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; @@ -160,25 +159,28 @@ const milestoneQuests: UserQuest[] = [ ), ]; +// Real entries from the achievements catalogue, so the shelf shows the +// actual artwork and rarity spread rather than one repeated placeholder. const achievement = ( - id: string, name: string, description: string, + image: string, + unit: string | null, progress: number, targetCount: number, rarity: number | null, unlockedAt: string | null, ): UserAchievement => ({ achievement: { - id, + id: name, name, description, - image: featuredAwardImage, + image, type: AchievementType.Milestone, criteria: { targetCount }, points: 100, rarity, - unit: 'posts', + unit, }, progress, unlockedAt, @@ -188,39 +190,63 @@ const achievement = ( const achievements: UserAchievement[] = [ achievement( - 'night-owl', - 'Night Owl', - 'Read 50 posts after midnight.', - 32, - 50, - null, + "Can't spend it all", + 'Spend 10000 Cores', + 'https://media.daily.dev/image/upload/s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all', null, + 10000, + 10000, + 0.003, + '2026-08-14T00:00:00.000Z', ), achievement( - 'deep-diver', - 'Deep Diver', - 'Finish 100 long reads.', + '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', + 64, 100, + 1.302, + null, + ), + achievement( + 'Hero', + 'Complete 100 quests', + 'https://media.daily.dev/image/upload/s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest', + null, + 38, 100, - 0.4, - '2026-08-10T00:00:00.000Z', + 0.064, + null, ), achievement( - 'first-light', - 'First Light', - 'Read on 30 consecutive mornings.', - 30, - 30, - 12, + 'Town crier', + 'Share a link (post)', + 'https://media.daily.dev/image/upload/v1770222937/achievements/Town_crier.png', + null, + 1, + 1, + 2.607, '2026-07-02T00:00:00.000Z', ), achievement( - 'tastemaker', - 'Tastemaker', - 'Have 250 upvotes on your comments.', - 180, - 250, - null, + "You're the cool kid!", + 'Receive 100 upvotes', + 'https://media.daily.dev/image/upload/v1770222932/achievements/You_re_the_cool_kid.png', + 'upvotes received', + 100, + 100, + 0.541, + '2026-06-19T00:00:00.000Z', + ), + achievement( + 'In the big league', + 'Gain 10000 reputation', + 'https://media.daily.dev/image/upload/v1770222928/achievements/In_the_big_league.png', + 'reputation', + 4120, + 10000, + 0.051, null, ), ]; 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/components/game-center/TrophyGrid.tsx b/packages/webapp/components/game-center/TrophyGrid.tsx index c801e3dcacd..b3a882f0ae9 100644 --- a/packages/webapp/components/game-center/TrophyGrid.tsx +++ b/packages/webapp/components/game-center/TrophyGrid.tsx @@ -19,7 +19,7 @@ const Cell = ({ award }: { award: AwardWithRarity }): ReactElement => {
{ loading="lazy" className="size-16 object-contain drop-shadow-[0_8px_12px_rgba(0,0,0,0.4)] transition-transform group-hover:scale-105" /> - + {award.name} { export const TrophyGrid = ({ awards }: TrophyGridProps): ReactElement => { return (
From c129a797730fcd78058b344fd99b67b8f7ade46b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 15:25:14 +0300 Subject: [PATCH 10/67] feat(game-center): drop the card frame around the progress snapshot The progress snapshot loses its rounded border, subtle background, inner padding, and the two blurred accent glows that only existed to fill that box. The eyebrow, greeting, level HUD, and the upcoming-milestone and closest-achievement tiles now sit directly on the page, matching how every other section on the page is framed. Collapses the two redundant wrapper divs the box left behind. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 8 +- packages/webapp/pages/game-center/index.tsx | 303 ++++++++---------- 2 files changed, 144 insertions(+), 167 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index f5541b1d03b..67741146dc1 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -283,12 +283,8 @@ const dividerClass = 'bg-border-subtlest-tertiary'; const GameCenterRedesign = () => (
-
-
-
-
-
-
+
+
)} -
-
-
-
+
+
+ + Progress snapshot + + + {firstName}, here's how you're doing. + + + 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. +
-
-
-
+ + {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.'} + +
- {questDashboard ? ( - +
+ + 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.'} - -
-
-
- )}
-
+ )}
From 66de2af8499136b9dd2750d50f1783f5529a3f06 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 15:44:51 +0300 Subject: [PATCH 11/67] fix(game-center): match the achievement shelf to the slab spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns AchievementShelfCard with the Slab design one property at a time: the four-stop scrim gradient, a 2px rarity ring as its own layer with the 0 0 16px -2px glow, solid-fill rarity pills in #efab27 / #1dbf8c on #08110c text, a 24px translucent Track/Tracked control, 14.5/11.5/10.5px type, a single-line description, thousands-separated progress, and a 5px white bar. Locked art now uses grayscale(.85) rather than a full desat. The slab only distinguishes two rarity bands, so the shared four-tier scale collapses onto them locally and the other achievement surfaces keep their own tiers. The card was a - ) : ( - - )} - + )} -
+
{achievement.name} - + {achievement.description} - {showProgress ? ( -
- - {progress}/{targetCount} - - -
+ + {isUnlocked ? ( + + Unlocked{' '} + {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + ) : ( - isUnlocked && - unlockedAt && ( - - Unlocked{' '} - {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + <> + + {progressLabel} - ) +
+
+
+ )}
- +
{isExpanded && ( setIsExpanded(false)} kind={ModalKind.FlexibleCenter} size={ModalSize.XSmall} + className="overflow-hidden" >
@@ -193,67 +188,66 @@ export function AchievementShelfCard({ alt={achievement.name} className={classNames( 'size-full object-cover', - !isUnlocked && 'brightness-[.6] grayscale', + !isUnlocked && 'brightness-[.6] grayscale-[.85]', )} /> - {rarityTier && ( - + {slabTier && ( + {rarityLabel} rare )} setIsExpanded(false)} />
-
+
{achievement.name} {achievement.description} - {showProgress ? ( -
- - {progress}/{targetCount} - - -
+ + {isUnlocked ? ( + + Unlocked{' '} + {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + ) : ( - isUnlocked && - unlockedAt && ( + <> - Unlocked{' '} - {formatDate({ - value: unlockedAt, - type: TimeFormatType.Post, - })} + {progressLabel} - ) +
+
+
+ )}
diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 67741146dc1..648df11d11a 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -190,63 +190,43 @@ const achievement = ( const achievements: UserAchievement[] = [ achievement( - "Can't spend it all", - 'Spend 10000 Cores', - 'https://media.daily.dev/image/upload/s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all', - null, - 10000, - 10000, - 0.003, - '2026-08-14T00:00:00.000Z', - ), - 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', - 64, - 100, - 1.302, + 'Committed', + 'Reach a 50-day reading streak', + 'https://media.daily.dev/image/upload/v1770222887/achievements/Comitted.png', null, + 50, + 50, + 1.99, + '2026-08-02T00:00:00.000Z', ), achievement( - 'Hero', - 'Complete 100 quests', - 'https://media.daily.dev/image/upload/s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest', - null, - 38, - 100, - 0.064, + 'In the big league', + 'Gain 10000 reputation', + 'https://media.daily.dev/image/upload/v1770222928/achievements/In_the_big_league.png', + 'reputation', + 6420, + 10000, + 0.051, null, ), achievement( - 'Town crier', - 'Share a link (post)', - 'https://media.daily.dev/image/upload/v1770222937/achievements/Town_crier.png', + 'Boosted', + 'Boost a post', + 'https://media.daily.dev/image/upload/v1770222884/achievements/Boosted.png', null, 1, 1, - 2.607, - '2026-07-02T00:00:00.000Z', + 0.061, + '2026-08-09T00: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', - 100, + 63, 100, 0.541, - '2026-06-19T00:00:00.000Z', - ), - achievement( - 'In the big league', - 'Gain 10000 reputation', - 'https://media.daily.dev/image/upload/v1770222928/achievements/In_the_big_league.png', - 'reputation', - 4120, - 10000, - 0.051, null, ), ]; @@ -335,6 +315,9 @@ const GameCenterRedesign = () => ( key={item.achievement.id} userAchievement={item} isOwner + isTracked={item.achievement.name === "You're the cool kid!"} + onTrack={async () => undefined} + onUntrack={async () => undefined} /> ))}
@@ -381,8 +364,12 @@ const meta: Meta = { 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. - +
+ +
), ], From aa5ca8719c7aa7d9349a6605c477fd896aba689a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 15:51:26 +0300 Subject: [PATCH 12/67] fix(game-center): spell out the achievement unlock date as "Aug 2" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slab writes the unlock date unpadded and always as a date. The shared post formatter zero-pads the day ("Aug 02") and substitutes "Today" / "Yesterday" for recent ones, so the shelf formats its own date instead of reusing it — posts keep the relative wording they rely on. Co-Authored-By: Claude Opus 5 --- .../achievements/AchievementShelfCard.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index bb128dc79cb..804eb28720f 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -13,7 +13,6 @@ import { ButtonSize, ButtonVariant, } from '../../../../components/buttons/Button'; -import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; import { LazyImage } from '../../../../components/LazyImage'; import CloseButton from '../../../../components/CloseButton'; import { Modal } from '../../../../components/modals/common/Modal'; @@ -38,6 +37,19 @@ interface AchievementShelfCardProps { 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) => @@ -154,8 +166,7 @@ export function AchievementShelfCard({ {isUnlocked ? ( - Unlocked{' '} - {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + Unlocked {formatUnlockedAt(unlockedAt)} ) : ( <> @@ -229,8 +240,7 @@ export function AchievementShelfCard({ color={TypographyColor.Tertiary} className="mt-2.5" > - Unlocked{' '} - {formatDate({ value: unlockedAt, type: TimeFormatType.Post })} + Unlocked {formatUnlockedAt(unlockedAt)} ) : ( <> From c04cdf01c20aaa93f2d63a096fa7f02bbdb3ea0c Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 16:16:08 +0300 Subject: [PATCH 13/67] fix(game-center): let the achievement art fill the slab The image sat in the top of the card instead of behind it. LazyImage appends its own `relative` after the caller's classes, and `.relative` is emitted after `.absolute` in the compiled CSS, so passing `absolute` via className silently lost and the figure laid out as a flex item. Setting the `absolute` prop is the only way to win that cascade. Co-Authored-By: Claude Opus 5 --- .../profile/components/achievements/AchievementShelfCard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index 804eb28720f..54c26c1a16b 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -94,11 +94,14 @@ export function AchievementShelfCard({ return ( <>
+ {/* `absolute` has to come from the prop: LazyImage appends its own + `relative` after our classes, and that wins in the compiled CSS. */} Date: Sun, 23 Aug 2026 17:00:45 +0300 Subject: [PATCH 14/67] feat(game-center): sticky holographic hero card beside the page content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the progress snapshot with a collectible-card treatment of the reader themselves: avatar in a framed art window, level as the headline stat, segmented XP bar, a streak/longest/badges stat block, and the next claimable milestone as flavour text. The card sticks to the left at laptop+ while every section — milestones, community pulse, achievement shelf, badge case, trophy case — scrolls past it on the right, so the level and the next claim stay in view for the whole page. The holographic effect follows simeydotme/pokemon-cards-css: pointer position drives --pointer-*, --background-* and --rotate-* custom properties, and CSS paints a color-dodge rainbow foil plus a radial glare from them. Both are held well below full strength because color-dodge over the gold frame blows out, and both are disabled under prefers-reduced-motion. Drops the greeting, the level HUD and the upcoming/closest tiles the card now covers. Achievement tracking is unaffected — it moved onto the shelf cards earlier, so the old pin control was already redundant. Page tests render without a QueryClientProvider, so useQueryClient is stubbed alongside the existing useQuery mock for the card's avatar. Co-Authored-By: Claude Opus 5 --- packages/shared/src/styles/base.css | 103 +++ .../pages/GameCenterRedesign.stories.tsx | 43 +- .../__tests__/GameCenterStaticProps.spec.ts | 49 +- .../components/game-center/HeroCard.tsx | 197 ++++++ .../components/game-center/useHoloPointer.ts | 74 +++ packages/webapp/pages/game-center/index.tsx | 605 +++++++----------- 6 files changed, 648 insertions(+), 423 deletions(-) create mode 100644 packages/webapp/components/game-center/HeroCard.tsx create mode 100644 packages/webapp/components/game-center/useHoloPointer.ts diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index 68e4f8c60bf..ddbd8ed7f1a 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1119,6 +1119,109 @@ meter::-webkit-meter-bar { } } + /* Holographic trading-card treatment, after simeydotme/pokemon-cards-css: + the pointer drives --pointer-* / --rotate-*, a repeating rainbow foil + rides --background-*, and a radial glare tracks the cursor. */ + .hero-card { + --pointer-x: 50%; + --pointer-y: 50%; + --background-x: 50%; + --background-y: 50%; + --rotate-x: 0deg; + --rotate-y: 0deg; + --card-opacity: 0; + perspective: 900px; + } + + .hero-card-inner { + transform: rotateY(var(--rotate-x)) rotateX(var(--rotate-y)); + transform-origin: center; + transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1); + will-change: transform; + } + + .hero-card.is-active .hero-card-inner { + transition: none; + } + + .hero-card-shine, + .hero-card-glare { + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + opacity: var(--card-opacity); + transition: opacity 0.3s ease-out; + } + + .hero-card-shine { + /* color-dodge over the gold frame blows out at full strength, so the + foil is held back to a sheen rather than a full rainbow wash. */ + opacity: calc(var(--card-opacity) * 0.45); + mix-blend-mode: color-dodge; + background-position: var(--background-x) var(--background-y); + background-size: 300% 300%; + background-image: repeating-linear-gradient( + 0deg, + rgb(255 119 115 / 0.5) 4%, + rgb(255 237 95 / 0.5) 8%, + rgb(168 255 95 / 0.5) 12%, + rgb(131 255 247 / 0.5) 16%, + rgb(120 148 255 / 0.5) 20%, + rgb(216 117 255 / 0.5) 24%, + rgb(255 119 115 / 0.5) 28% + ); + filter: brightness(0.7) contrast(1.9) saturate(1.4); + } + + .hero-card-glare { + opacity: calc(var(--card-opacity) * 0.6); + mix-blend-mode: overlay; + background-image: radial-gradient( + farthest-corner circle at var(--pointer-x) var(--pointer-y), + rgb(255 255 255 / 0.8) 10%, + rgb(255 255 255 / 0.65) 20%, + rgb(0 0 0 / 0.5) 90% + ); + } + + @media (prefers-reduced-motion: reduce) { + .hero-card-inner { + transform: none; + } + + .hero-card-shine, + .hero-card-glare { + opacity: 0; + } + } + + @keyframes hero-card-rays { + to { + transform: rotate(360deg); + } + } + + .hero-card-rays { + background: conic-gradient( + from 0deg, + rgb(206 61 243 / 0.3) 0 12deg, + transparent 12deg 30deg + ); + animation: hero-card-rays 22s linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + .hero-card-foil::after { + animation: none; + opacity: 0.25; + } + + .hero-card-rays { + animation: none; + } + } + @keyframes float { 0%, 100% { transform: translateY(0); diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 648df11d11a..1155759f7cb 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -1,7 +1,7 @@ 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 { HeroCard } from '../../../webapp/components/game-center/HeroCard'; 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'; @@ -263,19 +263,18 @@ const dividerClass = 'bg-border-subtlest-tertiary'; const GameCenterRedesign = () => (
-
-
- - Progress snapshot - - - Tomer, here's how you're doing. - - +
+ ( currentStreak={12} longestStreak={28} achievements={{ unlocked: 9, total: 24 }} - isPending={false} + footnote={ + + + Upvote 200 posts + {' '} + is ready to claim. + + } />
-
+
@@ -353,6 +362,8 @@ const GameCenterRedesign = () => ( />
+
+
); diff --git a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index 96c6825df95..19f963e37d1 100644 --- a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -42,6 +42,9 @@ jest.mock('@tanstack/react-query', () => { return { ...actual, useQuery: jest.fn(), + // The hero card renders a ProfilePicture, which reads the request protocol + // off the query client; the page tests render without a provider. + useQueryClient: jest.fn(() => ({ getQueryData: jest.fn() })), }; }); @@ -403,7 +406,9 @@ describe('game center client gating', () => { expect(screen.getByText('Milestone quests')).toBeInTheDocument(); expect(screen.getByText('Reader marathon')).toBeInTheDocument(); - expect(screen.getByText('No upcoming milestone yet')).toBeInTheDocument(); + expect( + screen.getByText('Your next milestone will show up here.'), + ).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Claim' })); @@ -487,7 +492,7 @@ describe('game center client gating', () => { }); }); - it('should highlight the most progressed milestone in the progress snapshot card', () => { + it('should highlight the most progressed milestone on the hero card', () => { mockUseConditionalFeature.mockReturnValue({ value: false, isLoading: false, @@ -582,22 +587,14 @@ describe('game center client gating', () => { }), ); - const upcomingMilestoneCard = screen - .getByText('Upcoming milestone') - .closest('div'); + const nextUp = screen.getByText('Next up').closest('div'); - expect(upcomingMilestoneCard).not.toBeNull(); - expect( - within(upcomingMilestoneCard as HTMLElement).getByText( - 'Almost there milestone', - ), - ).toBeInTheDocument(); - expect( - within(upcomingMilestoneCard as HTMLElement).getByText('7/8 progress'), - ).toBeInTheDocument(); + expect(nextUp).not.toBeNull(); + expect(nextUp).toHaveTextContent('Almost there milestone'); + expect(nextUp).toHaveTextContent('7/8 so far'); }); - it('should skip claimable milestones in the progress snapshot card and show the next upcoming one', () => { + it('should skip claimable milestones on the hero card and show the next upcoming one', () => { mockUseConditionalFeature.mockReturnValue({ value: false, isLoading: false, @@ -672,24 +669,12 @@ describe('game center client gating', () => { }), ); - const upcomingMilestoneCard = screen - .getByText('Upcoming milestone') - .closest('div'); + const nextUp = screen.getByText('Next up').closest('div'); - expect(upcomingMilestoneCard).not.toBeNull(); - expect( - within(upcomingMilestoneCard as HTMLElement).queryByText( - 'Ready to claim milestone', - ), - ).not.toBeInTheDocument(); - expect( - within(upcomingMilestoneCard as HTMLElement).getByText( - 'Next upcoming milestone', - ), - ).toBeInTheDocument(); - expect( - within(upcomingMilestoneCard as HTMLElement).getByText('7/8 progress'), - ).toBeInTheDocument(); + expect(nextUp).not.toBeNull(); + expect(nextUp).not.toHaveTextContent('Ready to claim milestone'); + expect(nextUp).toHaveTextContent('Next upcoming milestone'); + expect(nextUp).toHaveTextContent('7/8 so far'); }); it('should render every milestone quest as a stacked card without a show more toggle', () => { diff --git a/packages/webapp/components/game-center/HeroCard.tsx b/packages/webapp/components/game-center/HeroCard.tsx new file mode 100644 index 00000000000..42c0618ebbb --- /dev/null +++ b/packages/webapp/components/game-center/HeroCard.tsx @@ -0,0 +1,197 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + MedalBadgeIcon, + ReadingStreakIcon, + StarIcon, +} from '@dailydotdev/shared/src/components/icons'; +import classNames from 'classnames'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { useHoloPointer } from './useHoloPointer'; + +const xpSegmentCount = 10; + +type HeroCardProps = { + user: LoggedUser; + level: number; + levelProgress: number; + totalXp: number; + xpToNextLevel: number; + currentStreak: number; + longestStreak: number; + achievements?: { unlocked: number; total: number }; + footnote?: ReactNode; +}; + +const Stat = ({ + icon, + label, + value, +}: { + icon: ReactElement; + label: string; + value: string; +}): ReactElement => ( +
+
+ {icon} + + {label} + +
+ + {value} + +
+); + +export const HeroCard = ({ + user, + level, + levelProgress, + totalXp, + xpToNextLevel, + currentStreak, + longestStreak, + achievements, + footnote, +}: HeroCardProps): ReactElement => { + const filledSegments = Math.round((levelProgress / 100) * xpSegmentCount); + const holo = useHoloPointer(); + + return ( +
+
+
+
+
+ + {user.name} + + + @{user.username} + +
+
+ + LVL + + + {level} + +
+
+ +
+
+
+ + + + {currentStreak}d + +
+ +
+
+ + {xpToNextLevel.toLocaleString()} XP to level {level + 1} + + + {totalXp.toLocaleString()} XP + +
+
+ {Array.from({ length: xpSegmentCount }, (_, index) => ( + + ))} +
+
+ +
+ } + label="Streak" + value={`${currentStreak}d`} + /> + } + label="Longest" + value={`${longestStreak}d`} + /> + } + label="Badges" + value={ + achievements + ? `${achievements.unlocked}/${achievements.total}` + : '—' + } + /> +
+ + {footnote && ( +
+ {footnote} +
+ )} +
+ +
+
+
+
+ ); +}; diff --git a/packages/webapp/components/game-center/useHoloPointer.ts b/packages/webapp/components/game-center/useHoloPointer.ts new file mode 100644 index 00000000000..168c26718b4 --- /dev/null +++ b/packages/webapp/components/game-center/useHoloPointer.ts @@ -0,0 +1,74 @@ +import type { CSSProperties, PointerEvent, RefObject } from 'react'; +import { useCallback, useRef, useState } from 'react'; + +const maxRotation = 14; + +type HoloPointer = { + ref: RefObject; + isActive: boolean; + style: CSSProperties; + onPointerMove: (event: PointerEvent) => void; + onPointerLeave: () => void; +}; + +const neutral: CSSProperties = { + '--pointer-x': '50%', + '--pointer-y': '50%', + '--background-x': '50%', + '--background-y': '50%', + '--rotate-x': '0deg', + '--rotate-y': '0deg', + '--card-opacity': 0, +} as CSSProperties; + +/** + * Pointer-driven holographic card, after simeydotme/pokemon-cards-css: the + * cursor's position within the card feeds the tilt, the foil offset and the + * glare centre as custom properties, so the CSS does all the painting. + */ +export const useHoloPointer = (): HoloPointer => { + const ref = useRef(null); + const [style, setStyle] = useState(neutral); + const [isActive, setIsActive] = useState(false); + + const onPointerMove = useCallback((event: PointerEvent) => { + const element = ref.current; + + if (!element) { + return; + } + + const rect = element.getBoundingClientRect(); + const percentX = ((event.clientX - rect.left) / rect.width) * 100; + const percentY = ((event.clientY - rect.top) / rect.height) * 100; + const clampedX = Math.min(100, Math.max(0, percentX)); + const clampedY = Math.min(100, Math.max(0, percentY)); + // Centre-relative, so the card tilts away from wherever the cursor is. + const offsetX = clampedX - 50; + const offsetY = clampedY - 50; + + setIsActive(true); + setStyle({ + '--pointer-x': `${clampedX}%`, + '--pointer-y': `${clampedY}%`, + '--background-x': `${35 + clampedX / 3.4}%`, + '--background-y': `${35 + clampedY / 3.4}%`, + '--rotate-x': `${(offsetX / 50) * maxRotation}deg`, + '--rotate-y': `${(-offsetY / 50) * maxRotation}deg`, + '--card-opacity': 1, + } as CSSProperties); + }, []); + + const onPointerLeave = useCallback(() => { + setIsActive(false); + setStyle(neutral); + }, []); + + return { + ref, + isActive, + style, + onPointerMove, + onPointerLeave, + }; +}; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 547c7ac71b7..d9b665871d7 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -19,7 +19,6 @@ import { userProductSummaryQueryOptions, } from '@dailydotdev/shared/src/graphql/njord'; import type { QuestType } from '@dailydotdev/shared/src/graphql/quests'; -import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { useProfileAchievements } from '@dailydotdev/shared/src/hooks/profile/useProfileAchievements'; @@ -34,7 +33,6 @@ 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 { getFirstName } from '@dailydotdev/shared/src/lib/user'; import { generateQueryKey, RequestKey, @@ -57,17 +55,9 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; 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, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; 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'; @@ -75,12 +65,12 @@ import { ArrowIcon, CoreIcon, MedalBadgeIcon, - PinIcon, } from '@dailydotdev/shared/src/components/icons'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; +import { HeroCard } from '../../components/game-center/HeroCard'; import { TrophyGrid } from '../../components/game-center/TrophyGrid'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; @@ -296,9 +286,7 @@ function GameCenterPage({ const levelProgress = questDashboard ? getQuestLevelProgress(questDashboard.level) : 0; - const firstName = user?.name ? getFirstName(user.name) : 'there'; const { featuredAchievements } = achievementSummary; - const [featuredAchievement] = featuredAchievements; const upcomingMilestoneQuest = useMemo( () => getMostProgressedQuest(milestoneQuests), [milestoneQuests], @@ -307,33 +295,6 @@ function GameCenterPage({ highestReputation.length > 0 || mostQuestsCompleted.length > 0; const milestoneHash = `#${gameCenterMilestoneSectionId}`; - const isFeaturedAchievementTrackable = - shouldTrackAchievements && - !!featuredAchievement && - !featuredAchievement.unlockedAt; - const isFeaturedAchievementTracked = - isFeaturedAchievementTrackable && - trackedAchievementState.trackedAchievement?.achievement.id === - featuredAchievement.achievement.id; - const isFeaturedAchievementTrackingPending = - trackedAchievementState.isPending || - trackedAchievementState.isTrackPending || - trackedAchievementState.isUntrackPending; - - const handleFeaturedAchievementTracking = async () => { - if (!isFeaturedAchievementTrackable || !featuredAchievement) { - return; - } - - if (isFeaturedAchievementTracked) { - await trackedAchievementState.untrackAchievement(); - return; - } - - await trackedAchievementState.trackAchievement( - featuredAchievement.achievement.id, - ); - }; const handleMilestoneClaim = useCallback( (userQuestId: string, questId: string, questType: QuestType) => { claimQuestReward({ @@ -359,6 +320,59 @@ function GameCenterPage({ ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [claimableMilestoneCount, milestoneHash, router.asPath, router.isReady]); + const heroCard = user ? ( + + + Next up + + + {upcomingMilestoneQuest ? ( + <> + + {upcomingMilestoneQuest.quest.name} + {' '} + —{' '} + {Math.min( + upcomingMilestoneQuest.progress, + upcomingMilestoneQuest.quest.targetCount, + )} + /{upcomingMilestoneQuest.quest.targetCount} so far. + + ) : ( + 'Your next milestone will show up here.' + )} + + + } + /> + ) : null; + let milestoneQuestContent: ReactElement; if (isQuestPending) { @@ -580,362 +594,203 @@ function GameCenterPage({
)} - -
-
- - Progress snapshot - - - {firstName}, here's how you're doing. - - +
+ {/* The card follows the reader down the page so the level, streak + and next claim stay in view while the sections scroll. */} +
{heroCard}
+ +
+
- 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. - -
+ - {questDashboard ? ( - - ) : ( - showAchievements && ( -
- - Personal highlight - - - {achievementSummary.unlockedCount}/ - {achievementSummary.totalCount} - - - achievements unlocked so far - -
- ) - )} + {milestoneQuestContent} +
-
-
- - 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 && ( -
-
- - Closest achievement - - {isFeaturedAchievementTrackable && ( - -
+ } + /> + + all-time community total + + } + />
-
- {featuredAchievement && ( - + {highestReputation.length > 0 && ( + + )} + {mostQuestsCompleted.length > 0 && ( + )} -
- - {featuredAchievement?.achievement.name ?? - 'No tracked achievement'} - - - {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 + } + /> + + {achievementShelfContent} +
+ + )} - {showAchievements && ( - <>
- - View all achievements - - - - ) : undefined - } + title="Badge case" + description="Every top-reader badge you've earned and the subjects you have gone deepest on." /> - {achievementShelfContent} + {badgeCaseContent}
- - )} - - -
- - - {badgeCaseContent} -
- - + -
- +
+ - {trophyCaseContent} -
+ {trophyCaseContent} +
+
+
From fbf0ce7cf20a4cf5fb3eaefce29eaf4fcdcd53da Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 17:02:24 +0300 Subject: [PATCH 15/67] Revert "feat(game-center): sticky holographic hero card beside the page content" This reverts commit 322afb27ba36d681b96e33932a3edd4207915603. --- packages/shared/src/styles/base.css | 103 --- .../pages/GameCenterRedesign.stories.tsx | 43 +- .../__tests__/GameCenterStaticProps.spec.ts | 49 +- .../components/game-center/HeroCard.tsx | 197 ------ .../components/game-center/useHoloPointer.ts | 74 --- packages/webapp/pages/game-center/index.tsx | 605 +++++++++++------- 6 files changed, 423 insertions(+), 648 deletions(-) delete mode 100644 packages/webapp/components/game-center/HeroCard.tsx delete mode 100644 packages/webapp/components/game-center/useHoloPointer.ts diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index ddbd8ed7f1a..68e4f8c60bf 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1119,109 +1119,6 @@ meter::-webkit-meter-bar { } } - /* Holographic trading-card treatment, after simeydotme/pokemon-cards-css: - the pointer drives --pointer-* / --rotate-*, a repeating rainbow foil - rides --background-*, and a radial glare tracks the cursor. */ - .hero-card { - --pointer-x: 50%; - --pointer-y: 50%; - --background-x: 50%; - --background-y: 50%; - --rotate-x: 0deg; - --rotate-y: 0deg; - --card-opacity: 0; - perspective: 900px; - } - - .hero-card-inner { - transform: rotateY(var(--rotate-x)) rotateX(var(--rotate-y)); - transform-origin: center; - transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1); - will-change: transform; - } - - .hero-card.is-active .hero-card-inner { - transition: none; - } - - .hero-card-shine, - .hero-card-glare { - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - opacity: var(--card-opacity); - transition: opacity 0.3s ease-out; - } - - .hero-card-shine { - /* color-dodge over the gold frame blows out at full strength, so the - foil is held back to a sheen rather than a full rainbow wash. */ - opacity: calc(var(--card-opacity) * 0.45); - mix-blend-mode: color-dodge; - background-position: var(--background-x) var(--background-y); - background-size: 300% 300%; - background-image: repeating-linear-gradient( - 0deg, - rgb(255 119 115 / 0.5) 4%, - rgb(255 237 95 / 0.5) 8%, - rgb(168 255 95 / 0.5) 12%, - rgb(131 255 247 / 0.5) 16%, - rgb(120 148 255 / 0.5) 20%, - rgb(216 117 255 / 0.5) 24%, - rgb(255 119 115 / 0.5) 28% - ); - filter: brightness(0.7) contrast(1.9) saturate(1.4); - } - - .hero-card-glare { - opacity: calc(var(--card-opacity) * 0.6); - mix-blend-mode: overlay; - background-image: radial-gradient( - farthest-corner circle at var(--pointer-x) var(--pointer-y), - rgb(255 255 255 / 0.8) 10%, - rgb(255 255 255 / 0.65) 20%, - rgb(0 0 0 / 0.5) 90% - ); - } - - @media (prefers-reduced-motion: reduce) { - .hero-card-inner { - transform: none; - } - - .hero-card-shine, - .hero-card-glare { - opacity: 0; - } - } - - @keyframes hero-card-rays { - to { - transform: rotate(360deg); - } - } - - .hero-card-rays { - background: conic-gradient( - from 0deg, - rgb(206 61 243 / 0.3) 0 12deg, - transparent 12deg 30deg - ); - animation: hero-card-rays 22s linear infinite; - } - - @media (prefers-reduced-motion: reduce) { - .hero-card-foil::after { - animation: none; - opacity: 0.25; - } - - .hero-card-rays { - animation: none; - } - } - @keyframes float { 0%, 100% { transform: translateY(0); diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 1155759f7cb..648df11d11a 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { HeroCard } from '../../../webapp/components/game-center/HeroCard'; +import { LevelHud } from '@dailydotdev/shared/src/components/quest/LevelHud'; 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'; @@ -263,18 +263,19 @@ const dividerClass = 'bg-border-subtlest-tertiary'; const GameCenterRedesign = () => (
-
-
- +
+ + Progress snapshot + + + Tomer, here's how you're doing. + + ( currentStreak={12} longestStreak={28} achievements={{ unlocked: 9, total: 24 }} - footnote={ - - - Upvote 200 posts - {' '} - is ready to claim. - - } + isPending={false} />
+ -
@@ -362,8 +353,6 @@ const GameCenterRedesign = () => ( />
-
-
); diff --git a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index 19f963e37d1..96c6825df95 100644 --- a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -42,9 +42,6 @@ jest.mock('@tanstack/react-query', () => { return { ...actual, useQuery: jest.fn(), - // The hero card renders a ProfilePicture, which reads the request protocol - // off the query client; the page tests render without a provider. - useQueryClient: jest.fn(() => ({ getQueryData: jest.fn() })), }; }); @@ -406,9 +403,7 @@ describe('game center client gating', () => { expect(screen.getByText('Milestone quests')).toBeInTheDocument(); expect(screen.getByText('Reader marathon')).toBeInTheDocument(); - expect( - screen.getByText('Your next milestone will show up here.'), - ).toBeInTheDocument(); + expect(screen.getByText('No upcoming milestone yet')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Claim' })); @@ -492,7 +487,7 @@ describe('game center client gating', () => { }); }); - it('should highlight the most progressed milestone on the hero card', () => { + it('should highlight the most progressed milestone in the progress snapshot card', () => { mockUseConditionalFeature.mockReturnValue({ value: false, isLoading: false, @@ -587,14 +582,22 @@ describe('game center client gating', () => { }), ); - const nextUp = screen.getByText('Next up').closest('div'); + const upcomingMilestoneCard = screen + .getByText('Upcoming milestone') + .closest('div'); - expect(nextUp).not.toBeNull(); - expect(nextUp).toHaveTextContent('Almost there milestone'); - expect(nextUp).toHaveTextContent('7/8 so far'); + expect(upcomingMilestoneCard).not.toBeNull(); + expect( + within(upcomingMilestoneCard as HTMLElement).getByText( + 'Almost there milestone', + ), + ).toBeInTheDocument(); + expect( + within(upcomingMilestoneCard as HTMLElement).getByText('7/8 progress'), + ).toBeInTheDocument(); }); - it('should skip claimable milestones on the hero card and show the next upcoming one', () => { + it('should skip claimable milestones in the progress snapshot card and show the next upcoming one', () => { mockUseConditionalFeature.mockReturnValue({ value: false, isLoading: false, @@ -669,12 +672,24 @@ describe('game center client gating', () => { }), ); - const nextUp = screen.getByText('Next up').closest('div'); + const upcomingMilestoneCard = screen + .getByText('Upcoming milestone') + .closest('div'); - expect(nextUp).not.toBeNull(); - expect(nextUp).not.toHaveTextContent('Ready to claim milestone'); - expect(nextUp).toHaveTextContent('Next upcoming milestone'); - expect(nextUp).toHaveTextContent('7/8 so far'); + expect(upcomingMilestoneCard).not.toBeNull(); + expect( + within(upcomingMilestoneCard as HTMLElement).queryByText( + 'Ready to claim milestone', + ), + ).not.toBeInTheDocument(); + expect( + within(upcomingMilestoneCard as HTMLElement).getByText( + 'Next upcoming milestone', + ), + ).toBeInTheDocument(); + expect( + within(upcomingMilestoneCard as HTMLElement).getByText('7/8 progress'), + ).toBeInTheDocument(); }); it('should render every milestone quest as a stacked card without a show more toggle', () => { diff --git a/packages/webapp/components/game-center/HeroCard.tsx b/packages/webapp/components/game-center/HeroCard.tsx deleted file mode 100644 index 42c0618ebbb..00000000000 --- a/packages/webapp/components/game-center/HeroCard.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; -import { - ProfileImageSize, - ProfilePicture, -} from '@dailydotdev/shared/src/components/ProfilePicture'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '@dailydotdev/shared/src/components/typography/Typography'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { - MedalBadgeIcon, - ReadingStreakIcon, - StarIcon, -} from '@dailydotdev/shared/src/components/icons'; -import classNames from 'classnames'; -import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; -import { useHoloPointer } from './useHoloPointer'; - -const xpSegmentCount = 10; - -type HeroCardProps = { - user: LoggedUser; - level: number; - levelProgress: number; - totalXp: number; - xpToNextLevel: number; - currentStreak: number; - longestStreak: number; - achievements?: { unlocked: number; total: number }; - footnote?: ReactNode; -}; - -const Stat = ({ - icon, - label, - value, -}: { - icon: ReactElement; - label: string; - value: string; -}): ReactElement => ( -
-
- {icon} - - {label} - -
- - {value} - -
-); - -export const HeroCard = ({ - user, - level, - levelProgress, - totalXp, - xpToNextLevel, - currentStreak, - longestStreak, - achievements, - footnote, -}: HeroCardProps): ReactElement => { - const filledSegments = Math.round((levelProgress / 100) * xpSegmentCount); - const holo = useHoloPointer(); - - return ( -
-
-
-
-
- - {user.name} - - - @{user.username} - -
-
- - LVL - - - {level} - -
-
- -
-
-
- - - - {currentStreak}d - -
- -
-
- - {xpToNextLevel.toLocaleString()} XP to level {level + 1} - - - {totalXp.toLocaleString()} XP - -
-
- {Array.from({ length: xpSegmentCount }, (_, index) => ( - - ))} -
-
- -
- } - label="Streak" - value={`${currentStreak}d`} - /> - } - label="Longest" - value={`${longestStreak}d`} - /> - } - label="Badges" - value={ - achievements - ? `${achievements.unlocked}/${achievements.total}` - : '—' - } - /> -
- - {footnote && ( -
- {footnote} -
- )} -
- -
-
-
-
- ); -}; diff --git a/packages/webapp/components/game-center/useHoloPointer.ts b/packages/webapp/components/game-center/useHoloPointer.ts deleted file mode 100644 index 168c26718b4..00000000000 --- a/packages/webapp/components/game-center/useHoloPointer.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { CSSProperties, PointerEvent, RefObject } from 'react'; -import { useCallback, useRef, useState } from 'react'; - -const maxRotation = 14; - -type HoloPointer = { - ref: RefObject; - isActive: boolean; - style: CSSProperties; - onPointerMove: (event: PointerEvent) => void; - onPointerLeave: () => void; -}; - -const neutral: CSSProperties = { - '--pointer-x': '50%', - '--pointer-y': '50%', - '--background-x': '50%', - '--background-y': '50%', - '--rotate-x': '0deg', - '--rotate-y': '0deg', - '--card-opacity': 0, -} as CSSProperties; - -/** - * Pointer-driven holographic card, after simeydotme/pokemon-cards-css: the - * cursor's position within the card feeds the tilt, the foil offset and the - * glare centre as custom properties, so the CSS does all the painting. - */ -export const useHoloPointer = (): HoloPointer => { - const ref = useRef(null); - const [style, setStyle] = useState(neutral); - const [isActive, setIsActive] = useState(false); - - const onPointerMove = useCallback((event: PointerEvent) => { - const element = ref.current; - - if (!element) { - return; - } - - const rect = element.getBoundingClientRect(); - const percentX = ((event.clientX - rect.left) / rect.width) * 100; - const percentY = ((event.clientY - rect.top) / rect.height) * 100; - const clampedX = Math.min(100, Math.max(0, percentX)); - const clampedY = Math.min(100, Math.max(0, percentY)); - // Centre-relative, so the card tilts away from wherever the cursor is. - const offsetX = clampedX - 50; - const offsetY = clampedY - 50; - - setIsActive(true); - setStyle({ - '--pointer-x': `${clampedX}%`, - '--pointer-y': `${clampedY}%`, - '--background-x': `${35 + clampedX / 3.4}%`, - '--background-y': `${35 + clampedY / 3.4}%`, - '--rotate-x': `${(offsetX / 50) * maxRotation}deg`, - '--rotate-y': `${(-offsetY / 50) * maxRotation}deg`, - '--card-opacity': 1, - } as CSSProperties); - }, []); - - const onPointerLeave = useCallback(() => { - setIsActive(false); - setStyle(neutral); - }, []); - - return { - ref, - isActive, - style, - onPointerMove, - onPointerLeave, - }; -}; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index d9b665871d7..547c7ac71b7 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -19,6 +19,7 @@ import { userProductSummaryQueryOptions, } from '@dailydotdev/shared/src/graphql/njord'; import type { QuestType } from '@dailydotdev/shared/src/graphql/quests'; +import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { useProfileAchievements } from '@dailydotdev/shared/src/hooks/profile/useProfileAchievements'; @@ -33,6 +34,7 @@ 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 { getFirstName } from '@dailydotdev/shared/src/lib/user'; import { generateQueryKey, RequestKey, @@ -55,9 +57,17 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; 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, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; 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'; @@ -65,12 +75,12 @@ import { ArrowIcon, CoreIcon, MedalBadgeIcon, + PinIcon, } from '@dailydotdev/shared/src/components/icons'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; -import { HeroCard } from '../../components/game-center/HeroCard'; import { TrophyGrid } from '../../components/game-center/TrophyGrid'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; @@ -286,7 +296,9 @@ function GameCenterPage({ const levelProgress = questDashboard ? getQuestLevelProgress(questDashboard.level) : 0; + const firstName = user?.name ? getFirstName(user.name) : 'there'; const { featuredAchievements } = achievementSummary; + const [featuredAchievement] = featuredAchievements; const upcomingMilestoneQuest = useMemo( () => getMostProgressedQuest(milestoneQuests), [milestoneQuests], @@ -295,6 +307,33 @@ function GameCenterPage({ highestReputation.length > 0 || mostQuestsCompleted.length > 0; const milestoneHash = `#${gameCenterMilestoneSectionId}`; + const isFeaturedAchievementTrackable = + shouldTrackAchievements && + !!featuredAchievement && + !featuredAchievement.unlockedAt; + const isFeaturedAchievementTracked = + isFeaturedAchievementTrackable && + trackedAchievementState.trackedAchievement?.achievement.id === + featuredAchievement.achievement.id; + const isFeaturedAchievementTrackingPending = + trackedAchievementState.isPending || + trackedAchievementState.isTrackPending || + trackedAchievementState.isUntrackPending; + + const handleFeaturedAchievementTracking = async () => { + if (!isFeaturedAchievementTrackable || !featuredAchievement) { + return; + } + + if (isFeaturedAchievementTracked) { + await trackedAchievementState.untrackAchievement(); + return; + } + + await trackedAchievementState.trackAchievement( + featuredAchievement.achievement.id, + ); + }; const handleMilestoneClaim = useCallback( (userQuestId: string, questId: string, questType: QuestType) => { claimQuestReward({ @@ -320,59 +359,6 @@ function GameCenterPage({ ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [claimableMilestoneCount, milestoneHash, router.asPath, router.isReady]); - const heroCard = user ? ( - - - Next up - - - {upcomingMilestoneQuest ? ( - <> - - {upcomingMilestoneQuest.quest.name} - {' '} - —{' '} - {Math.min( - upcomingMilestoneQuest.progress, - upcomingMilestoneQuest.quest.targetCount, - )} - /{upcomingMilestoneQuest.quest.targetCount} so far. - - ) : ( - 'Your next milestone will show up here.' - )} - - - } - /> - ) : null; - let milestoneQuestContent: ReactElement; if (isQuestPending) { @@ -594,203 +580,362 @@ function GameCenterPage({ )} - -
- {/* The card follows the reader down the page so the level, streak - and next claim stay in view while the sections scroll. */} -
{heroCard}
- -
-
+
+
+ - + Progress snapshot + + + {firstName}, here's how you're doing. + + + 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. + +
- {milestoneQuestContent} -
+ {questDashboard ? ( + + ) : ( + showAchievements && ( +
+ + Personal highlight + + + {achievementSummary.unlockedCount}/ + {achievementSummary.totalCount} + + + 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.'} + +
-
- - - 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 && ( - + {showAchievements && ( +
+
+ + Closest achievement + + {isFeaturedAchievementTrackable && ( + +
+ )} +
+ - {showAchievements && ( - <> - - -
- - - View all achievements - - - - ) : undefined - } - /> + - {achievementShelfContent} -
- - )} +
+ - + {milestoneQuestContent} +
-
- + - {badgeCaseContent} -
+
+ + + 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 + } /> - {trophyCaseContent} + {achievementShelfContent}
-
-
+ + )} + + + +
+ + + {badgeCaseContent} +
+ + + +
+ + + {trophyCaseContent} +
From e38c73d63bcd6694d1c1ce4f08c797744099c3b2 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 17:50:44 +0300 Subject: [PATCH 16/67] feat(game-center): put each section's headline stat on its header row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trophy case gets "Total awards" and badge case gets "Topics mastered", both inline at the far right of their section header instead of stacked as tiles. The trophy total moves off the tile grid below rather than being repeated, leaving award types and most-earned as a two-up. Reuses DataTile through its container override, so the label, the info tooltip and the value keep their existing treatment and only the axis changes. That override needs !flex-row: flex-col is emitted after flex-row in the compiled CSS, so an unprefixed flex-row loses regardless of class order — same trap as border-0 and p-0 here. The achievement shelf already carried "View all achievements" in that slot as a text link; it is a Secondary button now, reading as the action it always was. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 20 ++++-- packages/webapp/pages/game-center/index.tsx | 71 +++++++++++++------ 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 648df11d11a..31472d5bbe7 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -19,6 +19,9 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { Divider } from '@dailydotdev/shared/src/components/utilities'; +import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; +import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; @@ -347,10 +350,19 @@ const GameCenterRedesign = () => (
- +
+ + } + className={{ container: '!flex-row items-center gap-2 !border-0 !p-0' }} + /> +
diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 547c7ac71b7..057a9eae46a 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -61,6 +61,7 @@ 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'; @@ -87,6 +88,7 @@ import { defaultOpenGraph } from '../../next-seo'; import { getAchievementSummary, getAwardSummary, + getBadgeSummary, getMostProgressedQuest, } from '../../lib/gameCenter'; @@ -432,6 +434,22 @@ function GameCenterPage({ ); } + const badgeTopics = + !isBadgesPending && topReaderBadges.length > 0 ? ( + + } + className={{ container: '!flex-row items-center gap-2 !border-0 !p-0' }} + /> + ) : undefined; + let badgeCaseContent: ReactElement; if (isBadgesPending) { @@ -465,6 +483,23 @@ function GameCenterPage({ ); } + const hasAwards = + hasCoresAccess && + !isAwardsPending && + !awardsError && + awardSummary.awards.length > 0; + // Laid out horizontally so it reads as one line beside the section title + // rather than as a stacked tile. + const trophyTotal = hasAwards ? ( + } + className={{ container: '!flex-row items-center gap-2 !border-0 !p-0' }} + /> + ) : undefined; + let trophyCaseContent: ReactElement; if (!hasCoresAccess) { @@ -491,23 +526,7 @@ function GameCenterPage({ } else if (awardSummary.awards.length > 0) { trophyCaseContent = ( <> -
- - } - subtitle={ - - all-time collection - - } - /> +
- - View all achievements - - - + ) : undefined } /> @@ -921,6 +944,7 @@ function GameCenterPage({ {badgeCaseContent} @@ -932,6 +956,7 @@ function GameCenterPage({ {trophyCaseContent} From 32b978bd47b10ac3270e79240e9b911adbd8c71b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 23 Aug 2026 18:06:53 +0300 Subject: [PATCH 17/67] feat(game-center): rebuild community pulse as an ambient band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stat tiles and two leaderboard panels become one card: a row of counters over avatar rails for each leaderboard, with podium rings and rank badges on the top three and the name, rank and score on hover. Roughly 900px of section becomes ~283px. The counters invert the old tiles. The quest name used to be the tile's value, rendered at Title2 in a third of the row, so both names truncated mid-word — "To the back of the que…". The completion count leads now and the quest name captions it, so the name survives. This also drops two duplicated numbers per leaderboard row: the old rows showed the score on the left and again beside the name, and in the quests board those were two different metrics — quests completed on the left, reputation beside the name — in near-identical positions. Storybook carries the four explored directions plus this one wired the way the page wires it, for future reference. Co-Authored-By: Claude Opus 5 --- .../pages/CommunityPulseDesigns.stories.tsx | 808 ++++++++++++++++++ .../components/game-center/CommunityPulse.tsx | 171 ++++ packages/webapp/pages/game-center/index.tsx | 115 +-- 3 files changed, 986 insertions(+), 108 deletions(-) create mode 100644 packages/storybook/stories/pages/CommunityPulseDesigns.stories.tsx create mode 100644 packages/webapp/components/game-center/CommunityPulse.tsx 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/webapp/components/game-center/CommunityPulse.tsx b/packages/webapp/components/game-center/CommunityPulse.tsx new file mode 100644 index 00000000000..6b381382c71 --- /dev/null +++ b/packages/webapp/components/game-center/CommunityPulse.tsx @@ -0,0 +1,171 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +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 { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; + +const railLength = 10; + +// Gold, silver, bronze for the first three; everyone else rides plain. +const podiumRing = [ + 'ring-accent-cheese-default', + 'ring-accent-salt-subtle', + 'ring-accent-bun-default', +]; + +const podiumBadge = [ + 'bg-accent-cheese-default', + 'bg-accent-salt-subtle', + 'bg-accent-bun-default', +]; + +type CounterProps = { + value: string; + label: string; + caption?: string; +}; + +const Counter = ({ value, label, caption }: CounterProps): ReactElement => ( +
+ + {value} + + + {label} + + {caption && ( + + {caption} + + )} +
+); + +type RailProps = { + label: string; + items: UserLeaderboard[]; + unit: string; +}; + +const Rail = ({ label, items, unit }: RailProps): ReactElement => ( +
+ + {label} + +
+ {items.slice(0, railLength).map((entry, index) => ( + + + + {index < 3 && ( + + {index + 1} + + )} + + + ))} +
+
+); + +type CommunityPulseProps = { + stats: QuestCompletionStats | null; + highestReputation: UserLeaderboard[]; + mostQuestsCompleted: UserLeaderboard[]; +}; + +export const CommunityPulse = ({ + stats, + highestReputation, + mostQuestsCompleted, +}: CommunityPulseProps): ReactElement => ( +
+ {stats && ( +
+ + {/* The count leads and the quest name captions it — the other way + round the name is what gets truncated, and it is the useful half. */} + {stats.allTimeLeader && ( + + )} + {stats.weeklyLeader && ( + + )} +
+ )} + + {(highestReputation.length > 0 || mostQuestsCompleted.length > 0) && ( +
+ {highestReputation.length > 0 && ( + + )} + {mostQuestsCompleted.length > 0 && ( + + )} +
+ )} +
+); diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 057a9eae46a..6c28a29f8e5 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -9,7 +9,6 @@ import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; import { HIGHEST_REPUTATION_QUERY, - LeaderboardType, MOST_QUESTS_COMPLETED_QUERY, QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; @@ -70,7 +69,6 @@ import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges 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, @@ -82,6 +80,7 @@ import { getLayout as getFooterNavBarLayout } from '../../components/layouts/Foo import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; +import { CommunityPulse } from '../../components/game-center/CommunityPulse'; import { TrophyGrid } from '../../components/game-center/TrophyGrid'; import ProtectedPage from '../../components/ProtectedPage'; import { defaultOpenGraph } from '../../next-seo'; @@ -115,10 +114,6 @@ const isQuestCompletionStatsSchemaMissing = (error: GraphQLError): boolean => { ); }; -const formatQuestCompletionCount = (count: number): string => { - return count === 1 ? '1 completion' : `${count.toLocaleString()} completions`; -}; - const SectionHeader = ({ title, description, @@ -799,108 +794,12 @@ function GameCenterPage({ } /> - {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 && ( - - )} -
+ {hasCommunityLeaderboards || questCompletionStats ? ( + ) : ( Date: Sun, 23 Aug 2026 21:36:27 +0300 Subject: [PATCH 18/67] feat(game-center): double the size of the achievement shelf cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scales the slab from 176x232 to 352x464 and every interior value with it — radius, ring width and glow, pill, track control, plate padding, type sizes and the progress bar — so the card reads as the same design at twice the size rather than a large card with small text. Co-Authored-By: Claude Opus 5 --- .../achievements/AchievementShelfCard.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index 54c26c1a16b..97d4453b1ec 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -56,8 +56,8 @@ const isEmerald = (tier: AchievementRarityTier | null) => tier === AchievementRarityTier.Emerald; const slabRingClasses: Record<'gold' | 'emerald', string> = { - gold: 'border-[#efab27] shadow-[0_0_16px_-2px_#efab27]', - emerald: 'border-[#1dbf8c] shadow-[0_0_16px_-2px_#1dbf8c]', + gold: 'border-[#efab27] shadow-[0_0_32px_-4px_#efab27]', + emerald: 'border-[#1dbf8c] shadow-[0_0_32px_-4px_#1dbf8c]', }; const slabPillClasses: Record<'gold' | 'emerald', string> = { @@ -93,7 +93,7 @@ export function AchievementShelfCard({ return ( <> -
+
{/* `absolute` has to come from the prop: LazyImage appends its own `relative` after our classes, and that wins in the compiled CSS. */} @@ -130,7 +130,7 @@ export function AchievementShelfCard({ {slabTier && ( @@ -142,7 +142,7 @@ export function AchievementShelfCard({ )} -
+
{achievement.name} - + {achievement.description} {isUnlocked ? ( - + Unlocked {formatUnlockedAt(unlockedAt)} ) : ( <> - + {progressLabel} -
+
Date: Sun, 23 Aug 2026 21:38:15 +0300 Subject: [PATCH 19/67] Revert "feat(game-center): double the size of the achievement shelf cards" This reverts commit 52332a8ff5dd8eee29fa2c05a7a772a60e13c884. --- .../achievements/AchievementShelfCard.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index 97d4453b1ec..54c26c1a16b 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -56,8 +56,8 @@ const isEmerald = (tier: AchievementRarityTier | null) => tier === AchievementRarityTier.Emerald; const slabRingClasses: Record<'gold' | 'emerald', string> = { - gold: 'border-[#efab27] shadow-[0_0_32px_-4px_#efab27]', - emerald: 'border-[#1dbf8c] shadow-[0_0_32px_-4px_#1dbf8c]', + gold: 'border-[#efab27] shadow-[0_0_16px_-2px_#efab27]', + emerald: 'border-[#1dbf8c] shadow-[0_0_16px_-2px_#1dbf8c]', }; const slabPillClasses: Record<'gold' | 'emerald', string> = { @@ -93,7 +93,7 @@ export function AchievementShelfCard({ return ( <> -
+
{/* `absolute` has to come from the prop: LazyImage appends its own `relative` after our classes, and that wins in the compiled CSS. */} @@ -130,7 +130,7 @@ export function AchievementShelfCard({ {slabTier && ( @@ -142,7 +142,7 @@ export function AchievementShelfCard({ )} -
+
{achievement.name} - + {achievement.description} {isUnlocked ? ( - + Unlocked {formatUnlockedAt(unlockedAt)} ) : ( <> - + {progressLabel} -
+
Date: Sun, 23 Aug 2026 21:55:16 +0300 Subject: [PATCH 20/67] feat(game-center): drop section subtext, floor type at 14px, polish badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes to the page's chrome: Section headers lose their descriptions, and the hero loses the paragraph under the greeting. The `description` prop is gone from SectionHeader entirely rather than left unused. EmptyStateCard keeps its descriptions; those are empty states, not headline subtext. Nothing renders below 14px any more. typo-footnote (13), typo-caption1 (12) and typo-caption2 (11) all become typo-subhead, and the slab card's arbitrary 10.5/11/11.5/13px values become 14px. The two text buttons move from ButtonSize.Small to Medium, since Small maps to typo-footnote — using the sanctioned size beats overriding it. Verified by walking every rendered leaf node: zero elements under 14px. DataTile is shared with analytics, squads analytics, World and Boost, so its 13px label could not be raised globally. It takes an optional className.label instead, which only the game center passes. That override needs `!` — typo-footnote is emitted after typo-subhead, so an unprefixed class loses regardless of order. Badge case boxes get a slow highlight travelling around a 1px border: a narrow, low-opacity arc on a 7s rotation, disabled under prefers-reduced-motion, plus a small lift on hover. Milestone quest cards drop their icon badges, along with the event-type to icon mapping that only fed them. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/DataTile.tsx | 5 +- .../badges/TopReaderBadgeCompact.tsx | 42 +++---- .../shared/src/components/quest/LevelHud.tsx | 6 +- .../achievements/AchievementShelfCard.tsx | 16 +-- packages/shared/src/styles/base.css | 30 +++++ .../pages/GameCenterRedesign.stories.tsx | 64 ++++------- .../components/game-center/CommunityPulse.tsx | 8 +- .../game-center/MilestoneQuestList.tsx | 105 ++---------------- .../components/game-center/TrophyGrid.tsx | 4 +- packages/webapp/pages/game-center/index.tsx | 94 ++++++---------- 10 files changed, 138 insertions(+), 236 deletions(-) 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 index 53cd148b23a..622191cb1a2 100644 --- a/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx +++ b/packages/shared/src/components/badges/TopReaderBadgeCompact.tsx @@ -20,31 +20,33 @@ export const TopReaderBadgeCompact = ({ }); return ( -
- - Top reader - - - - {formattedDate} - +
+
+ + Top reader + -
- {keyword.flags?.title || keyword.value} + {formattedDate} + +
+ + {keyword.flags?.title || keyword.value} + +
); diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index f4b73cda1ee..b35dacb7d5f 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -29,7 +29,7 @@ const HudStatTile = ({
{icon} {label} @@ -73,7 +73,7 @@ export const LevelHud = ({
LVL @@ -106,7 +106,7 @@ export const LevelHud = ({
total XP diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index 54c26c1a16b..4a4c39d6556 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -130,7 +130,7 @@ export function AchievementShelfCard({ {slabTier && ( @@ -142,7 +142,7 @@ export function AchievementShelfCard({
); } else { @@ -758,32 +762,6 @@ function GameCenterPage({ {milestoneQuestContent} -
- - - Open full leaderboards - - - - } - /> - {hasCommunityLeaderboards || questCompletionStats ? ( - - ) : ( - - )} -
- {showAchievements && ( <>
@@ -821,6 +799,32 @@ function GameCenterPage({ {trophyCaseContent}
+ +
+ + + Open full leaderboards + + + + } + /> + {hasCommunityLeaderboards || questCompletionStats ? ( + + ) : ( + + )} +
From 2ff0897d9dbab45baf36e97203aded9b7d55c136 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 12:32:55 +0300 Subject: [PATCH 36/67] chore: cherry-pick streak milestone offers (#6487) Brings the offers module onto this branch so the game center can claim them: the userOffers query, the shared offer primitives, and the RequestKey/LogEvent entries they need. Cherry-picked rather than merging main, to keep the redesign branch's history to the redesign. Cherry-picked from be2b55b45, no conflicts. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/modals/common.tsx | 8 + .../src/components/modals/common/types.ts | 1 + .../streaks/StreakMilestonePopup.spec.tsx | 71 ++++++ .../modals/streaks/StreakMilestonePopup.tsx | 92 ++++++-- .../modals/streaks/StreakOffersModal.spec.tsx | 221 ++++++++++++++++++ .../modals/streaks/StreakOffersModal.tsx | 175 ++++++++++++++ .../streak/offers/StreakOfferCarousel.tsx | 216 +++++++++++++++++ .../streak/offers/StreakOfferCelebration.tsx | 144 ++++++++++++ .../streak/offers/StreakOfferSplit.tsx | 103 ++++++++ .../src/components/streak/offers/common.tsx | 101 ++++++++ .../components/streak/offers/streakTiers.ts | 64 +++++ packages/shared/src/graphql/offers.ts | 81 +++++++ packages/shared/src/lib/featureManagement.ts | 8 + packages/shared/src/lib/log.ts | 2 + packages/shared/src/lib/query.ts | 1 + 15 files changed, 1264 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx create mode 100644 packages/shared/src/components/modals/streaks/StreakOffersModal.tsx create mode 100644 packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx create mode 100644 packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx create mode 100644 packages/shared/src/components/streak/offers/StreakOfferSplit.tsx create mode 100644 packages/shared/src/components/streak/offers/common.tsx create mode 100644 packages/shared/src/components/streak/offers/streakTiers.ts create mode 100644 packages/shared/src/graphql/offers.ts diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 56849f3a97d..a02bdc7d4da 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -107,6 +107,13 @@ const NewStreakModal = dynamic( import(/* webpackChunkName: "newStreakModal" */ './streaks/NewStreakModal'), ); +const StreakOffersModal = dynamic( + () => + import( + /* webpackChunkName: "streakOffersModal" */ './streaks/StreakOffersModal' + ), +); + const ReputationPrivilegesModal = dynamic( () => import( @@ -544,6 +551,7 @@ export const modals = { [LazyModal.Video]: VideoModal, [LazyModal.ImageView]: ImageModal, [LazyModal.NewStreak]: NewStreakModal, + [LazyModal.StreakOffers]: StreakOffersModal, [LazyModal.ReputationPrivileges]: ReputationPrivilegesModal, [LazyModal.MarketingCta]: MarketingCtaModal, [LazyModal.Share]: ShareModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 66e87110334..cb070ec0a8e 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -43,6 +43,7 @@ export enum LazyModal { Video = 'video', ImageView = 'imageView', NewStreak = 'newStreak', + StreakOffers = 'streakOffers', RecoverStreak = 'recoverStreak', StreakFreezePurchase = 'streakFreezePurchase', ReputationPrivileges = 'reputationPrivileges', diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx index 4306127bbca..d8bdead72b1 100644 --- a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx @@ -7,10 +7,14 @@ import type { Alerts } from '../../../graphql/alerts'; import { StreakMilestonePopup } from './StreakMilestonePopup'; import * as actionHook from '../../../hooks/useActions'; import * as streakHook from '../../../hooks/streaks/useReadingStreak'; +import * as conditionalFeatureHook from '../../../hooks/useConditionalFeature'; import { ActionType } from '../../../graphql/actions'; +import type { UserOffer } from '../../../graphql/offers'; +import { USER_OFFERS_QUERY } from '../../../graphql/offers'; import { LazyModal } from '../common/types'; import { MODAL_KEY } from '../../../hooks/useLazyModal'; import { DayOfWeek } from '../../../lib/date'; +import { mockGraphQL } from '../../../../__tests__/helpers/graphql'; const defaultAlerts: Alerts = { filter: true, @@ -68,6 +72,10 @@ const renderComponent = ({ beforeEach(() => { window.scrollTo = jest.fn(); + jest + .spyOn(conditionalFeatureHook, 'useConditionalFeature') + .mockReturnValue({ value: false, isLoading: false }); + jest.spyOn(actionHook, 'useActions').mockReturnValue({ completeAction: jest.fn(), checkHasCompleted, @@ -153,6 +161,69 @@ it('should not open when streaks are disabled', async () => { }); }); +describe('streak milestone offers experiment', () => { + const offers: UserOffer[] = [ + { + impressionUid: '10000000-0000-4000-8000-000000000001', + clickUrl: 'https://link.encorekit.com/one', + title: '3 Months of Music, Free', + advertiserName: 'Acme Music', + perk: '3 months free', + badgeLabel: 'free_trial', + }, + ]; + + const mockOffersResponse = (userOffers: UserOffer[]) => { + nock.cleanAll(); + mockGraphQL({ + request: { + query: USER_OFFERS_QUERY, + variables: { placement: 'STREAK_MILESTONE' }, + }, + result: { data: { userOffers } }, + }); + nock('http://localhost:3000') + .post('/graphql') + .optionally() + .times(10) + .reply(200, { data: {} }); + }; + + beforeEach(() => { + jest + .spyOn(conditionalFeatureHook, 'useConditionalFeature') + .mockReturnValue({ value: true, isLoading: false }); + }); + + it('should open the offers modal when treatment returns offers', async () => { + mockOffersResponse(offers); + + const { queryClient } = renderComponent(); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toMatchObject({ + type: LazyModal.StreakOffers, + props: { currentStreak: 5, offers }, + }); + }); + }); + + it('should fall back to the classic modal when treatment has no offers', async () => { + mockOffersResponse([]); + + const { queryClient } = renderComponent(); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toMatchObject({ + type: LazyModal.NewStreak, + props: { currentStreak: 5, maxStreak: 5 }, + }); + }); + }); +}); + it('should not open when another modal is already showing', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(MODAL_KEY, { diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx index 635a4813cb8..40de3d45853 100644 --- a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx @@ -1,11 +1,19 @@ import type { ReactElement } from 'react'; import { useContext, useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { useActions } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { LazyModal } from '../common/types'; import AlertContext from '../../../contexts/AlertContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; import { useReadingStreak } from '../../../hooks/streaks'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { + OfferPlacement, + userOffersQueryOptions, +} from '../../../graphql/offers'; +import { featureStreakMilestoneOffers } from '../../../lib/featureManagement'; import { isNullOrUndefined } from '../../../lib/func'; /** @@ -15,57 +23,93 @@ import { isNullOrUndefined } from '../../../lib/func'; * boot popup queue. The modal opens as soon as all conditions are met * (alerts loaded, streak data loaded, user eligible). */ -export const StreakMilestonePopup = (): ReactElement => { +export const StreakMilestonePopup = (): ReactElement | null => { const { openModal, modal } = useLazyModal(); const { checkHasCompleted, isActionsFetched } = useActions(); const { alerts, loadedAlerts, updateAlerts } = useContext(AlertContext); const { streak, isStreaksEnabled } = useReadingStreak(); + const { user } = useAuthContext(); const hasOpened = useRef(false); const isDisabledMilestone = checkHasCompleted( ActionType.DisableReadingStreakMilestone, ); + const shouldShow = ![ + !loadedAlerts, + !isStreaksEnabled, + !isActionsFetched, + isNullOrUndefined(isDisabledMilestone), + isDisabledMilestone, + alerts?.showStreakMilestone !== true, + !streak?.current, + !!modal, + ].some(Boolean); + + // Enrollment only happens when the popup would actually show, so users who + // never hit a milestone don't dilute the experiment split. + const { value: offersEnabled, isLoading: isOffersFeatureLoading } = + useConditionalFeature({ + feature: featureStreakMilestoneOffers, + shouldEvaluate: shouldShow, + }); + + const { data: offers, isPending: areOffersPending } = useQuery({ + ...userOffersQueryOptions({ + user, + placement: OfferPlacement.StreakMilestone, + }), + enabled: shouldShow && !isOffersFeatureLoading && offersEnabled, + }); + useEffect(() => { - if (hasOpened.current) { + if (hasOpened.current || !shouldShow || isOffersFeatureLoading) { return; } - const shouldHide = [ - !loadedAlerts, - !isStreaksEnabled, - !isActionsFetched, - isNullOrUndefined(isDisabledMilestone), - isDisabledMilestone, - alerts?.showStreakMilestone !== true, - !streak?.current, - !!modal, - ].some(Boolean); + // Treatment waits for the offers fetch to settle; an error resolves it + // (offers stays undefined) and falls back to the classic popup. + if (offersEnabled && areOffersPending) { + return; + } - if (shouldHide) { + if (!streak?.current) { return; } hasOpened.current = true; + const onAfterClose = () => { + updateAlerts?.({ showStreakMilestone: false }); + }; + + if (offersEnabled && offers?.length) { + openModal({ + type: LazyModal.StreakOffers, + props: { + currentStreak: streak.current, + offers, + onAfterClose, + }, + }); + return; + } + openModal({ type: LazyModal.NewStreak, props: { - currentStreak: streak?.current, - maxStreak: streak?.max, - onAfterClose: () => { - updateAlerts({ showStreakMilestone: false }); - }, + currentStreak: streak.current, + maxStreak: streak.max, + onAfterClose, }, }); }, [ - alerts?.showStreakMilestone, - isActionsFetched, - isDisabledMilestone, - isStreaksEnabled, - loadedAlerts, - modal, + areOffersPending, + isOffersFeatureLoading, + offers, + offersEnabled, openModal, + shouldShow, streak, updateAlerts, ]); diff --git a/packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx b/packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx new file mode 100644 index 00000000000..65f04bcc029 --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakOffersModal.spec.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { UserOffer } from '../../../graphql/offers'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useViewSize } from '../../../hooks/useViewSize'; +import StreakOffersModal from './StreakOffersModal'; + +const mockConfirmDelivered = jest.fn(); + +jest.mock('../../../graphql/offers', () => ({ + ...jest.requireActual('../../../graphql/offers'), + confirmOffersDelivered: (...args: unknown[]) => mockConfirmDelivered(...args), +})); + +jest.mock('../../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +// jsdom has no PointerEvent; MouseEvent carries the clientX the swipe needs +if (typeof window.PointerEvent === 'undefined') { + window.PointerEvent = MouseEvent as unknown as typeof PointerEvent; +} + +const mockUseViewSize = useViewSize as jest.Mock; +const logEvent = jest.fn(); +const onRequestClose = jest.fn(); + +const offers: UserOffer[] = [ + { + impressionUid: '10000000-0000-4000-8000-000000000001', + clickUrl: 'https://link.encorekit.com/one', + title: '3 Months of Music, Free', + advertiserName: 'Acme Music', + advertiserLogo: 'https://cdn.example.com/music.png', + perk: '3 months free', + badgeLabel: 'free_trial', + }, + { + impressionUid: '10000000-0000-4000-8000-000000000002', + clickUrl: 'https://link.encorekit.com/two', + title: 'Get 50% off Notes Pro', + advertiserName: 'Acme Notes', + advertiserLogo: 'https://cdn.example.com/notes.png', + perk: '50% off', + badgeLabel: 'discount', + }, +]; + +const renderComponent = ({ + currentStreak = 7, +}: { currentStreak?: number } = {}) => { + const client = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + + return render( + + + , + ); +}; + +describe('StreakOffersModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockConfirmDelivered.mockResolvedValue({ _: true }); + mockUseViewSize.mockReturnValue(false); + document.body.innerHTML = '
'; + }); + + it('renders all offers on desktop and confirms delivery once', async () => { + renderComponent(); + + expect(screen.getByText('3 Months of Music, Free')).toBeInTheDocument(); + expect(screen.getByText('Get 50% off Notes Pro')).toBeInTheDocument(); + // 7-day streak resolves to the Flame tier from the design ladder + expect(screen.getByText('day streak')).toBeInTheDocument(); + expect(screen.getByText('Flame')).toBeInTheDocument(); + expect(screen.getByText('A full week, unbroken')).toBeInTheDocument(); + + await waitFor(() => + expect(mockConfirmDelivered).toHaveBeenCalledWith( + offers.map((offer) => offer.impressionUid), + ), + ); + expect(mockConfirmDelivered).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.Impression, + target_type: TargetType.StreakOffer, + target_id: offers[0].impressionUid, + }), + ); + }); + + it('opens the tokenized click url and marks the offer claimed', async () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => window); + + renderComponent(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Claim' })[0]); + + expect(openSpy).toHaveBeenCalledWith( + offers[0].clickUrl, + '_blank', + 'noopener,noreferrer', + ); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.Click, + target_type: TargetType.StreakOffer, + target_id: offers[0].impressionUid, + }), + ); + await waitFor(() => + expect(screen.getByText('Claimed')).toBeInTheDocument(), + ); + }); + + it('derives copy from the streak for days off the design ladder', () => { + // the milestone alert fires on Fibonacci days (2, 8, ...) that the + // design ladder doesn't contain — copy must never contradict the count + renderComponent({ currentStreak: 8 }); + + expect(screen.getByText('Flame')).toBeInTheDocument(); + expect(screen.getByText('8 days in a row')).toBeInTheDocument(); + expect(screen.queryByText('A full week, unbroken')).not.toBeInTheDocument(); + }); + + it('falls back to the first tier below the ladder start', () => { + renderComponent({ currentStreak: 2 }); + + expect(screen.getByText('Spark')).toBeInTheDocument(); + expect(screen.getByText('2 days in a row')).toBeInTheDocument(); + }); + + it('logs a dismissal when closed via the X on desktop', () => { + renderComponent(); + + fireEvent.click(screen.getByTitle('Close')); + + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.DismissStreakOffers, + target_type: TargetType.StreakOffer, + extra: JSON.stringify({ method: 'close', claimed: 0 }), + }), + ); + expect(onRequestClose).toHaveBeenCalled(); + }); + + it('keeps the swiped card when the drag ends with a click on a card', async () => { + mockUseViewSize.mockReturnValue(true); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => window); + + renderComponent(); + + const firstCard = screen + .getByText(offers[0].advertiserName) + .closest('button'); + + if (!firstCard) { + throw new Error('carousel card not found'); + } + + // swipe left past the threshold; browsers then fire a click on the card + fireEvent.pointerDown(firstCard, { clientX: 200 }); + fireEvent.pointerMove(firstCard, { clientX: 80 }); + fireEvent.pointerUp(firstCard); + fireEvent.click(firstCard); + + fireEvent.click(screen.getByRole('button', { name: 'Claim gift' })); + + expect(openSpy).toHaveBeenCalledWith( + offers[1].clickUrl, + '_blank', + 'noopener,noreferrer', + ); + await waitFor(() => + expect(mockConfirmDelivered).toHaveBeenCalledWith([ + offers[1].impressionUid, + ]), + ); + }); + + it('confirms only the visible card on mobile and dismisses via no thanks', async () => { + mockUseViewSize.mockReturnValue(true); + + renderComponent(); + + await waitFor(() => + expect(mockConfirmDelivered).toHaveBeenCalledWith([ + offers[0].impressionUid, + ]), + ); + expect(mockConfirmDelivered).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'No thanks' })); + + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.DismissStreakOffers, + target_type: TargetType.StreakOffer, + extra: JSON.stringify({ method: 'decline', claimed: 0 }), + }), + ); + expect(onRequestClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/modals/streaks/StreakOffersModal.tsx b/packages/shared/src/components/modals/streaks/StreakOffersModal.tsx new file mode 100644 index 00000000000..32267b527dc --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakOffersModal.tsx @@ -0,0 +1,175 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UserOffer } from '../../../graphql/offers'; +import { confirmOffersDelivered } from '../../../graphql/offers'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; +import { useViewSize, ViewSize } from '../../../hooks/useViewSize'; +import useLogEventOnce from '../../../hooks/log/useLogEventOnce'; +import { StreakOfferCarousel } from '../../streak/offers/StreakOfferCarousel'; +import { StreakOfferSplit } from '../../streak/offers/StreakOfferSplit'; +import { Modal } from '../common/Modal'; +import { ModalClose } from '../common/ModalClose'; +import type { LazyModalCommonProps, ModalProps } from '../common/Modal'; + +export type StreakOffersModalProps = LazyModalCommonProps & + Pick & { + currentStreak: number; + offers: UserOffer[]; + }; + +export default function StreakOffersModal({ + currentStreak, + offers, + onRequestClose, + ...props +}: StreakOffersModalProps): ReactElement { + const isMobile = useViewSize(ViewSize.MobileL); + const queryClient = useQueryClient(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const [claimedUids, setClaimedUids] = useState>(new Set()); + const [deliveredUids] = useState>(new Set()); + const { mutate: confirmDelivered } = useMutation({ + mutationFn: confirmOffersDelivered, + }); + + useLogEventOnce(() => ({ + event_name: LogEvent.Impression, + target_type: TargetType.StreaksMilestone, + target_id: currentStreak?.toString(), + })); + + const invalidatedStreak = useRef(false); + + useEffect(() => { + if (invalidatedStreak.current) { + return; + } + + invalidatedStreak.current = true; + // the streaks query is cached with a staleTime; the popup moment is when + // it must refresh (mirrors NewStreakModal) + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.UserStreak, user), + }); + }, [queryClient, user]); + + // Render-then-confirm: each offer is confirmed once, at the moment it + // becomes visible. Encore does not dedupe, so the set guards replays. + const onVisible = useCallback( + (visibleOffers: UserOffer[]) => { + const fresh = visibleOffers.filter( + (offer) => !deliveredUids.has(offer.impressionUid), + ); + + if (!fresh.length) { + return; + } + + fresh.forEach((offer) => deliveredUids.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, deliveredUids, logEvent], + ); + + 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], + ); + + // Every dismissal path (X, backdrop, escape, "No thanks") funnels through + // here so the metric is comparable across variants and platforms; the + // method and claim count distinguish explicit declines in analysis. + const dismissLogged = useRef(false); + const logDismiss = useCallback( + (method: 'close' | 'decline') => { + if (dismissLogged.current) { + return; + } + + dismissLogged.current = true; + logEvent({ + event_name: LogEvent.DismissStreakOffers, + target_type: TargetType.StreakOffer, + target_id: currentStreak?.toString(), + extra: JSON.stringify({ method, claimed: claimedUids.size }), + }); + }, + [claimedUids.size, currentStreak, logEvent], + ); + + const onClose = useCallback( + (event?: React.MouseEvent | React.KeyboardEvent) => { + logDismiss('close'); + onRequestClose(event); + }, + [logDismiss, onRequestClose], + ); + + const onDecline = useCallback(() => { + logDismiss('decline'); + onRequestClose(); + }, [logDismiss, onRequestClose]); + + return ( + + {/* The celebration gradient must clip to the same radius as the modal + container (tablet:rounded-16) and the mobile drawer (rounded-t-16), + otherwise its square corners paint outside the rounded frame. */} + + + {isMobile ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx b/packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx new file mode 100644 index 00000000000..5a9c607affa --- /dev/null +++ b/packages/shared/src/components/streak/offers/StreakOfferCarousel.tsx @@ -0,0 +1,216 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { UserOffer } from '../../../graphql/offers'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + ClaimedChip, + FinePrint, + GiftHeadline, + OfferLogo, + offerBadgeLabels, +} from './common'; +import { StreakOfferCelebrationCompact } from './StreakOfferCelebration'; + +const CARD_STEP = 13; // rem: card width plus gap, used to slide the track. +const SWIPE_THRESHOLD = 48; // px of travel before a swipe counts as a move. + +const CarouselCard = ({ + offer, + isActive, + onSelect, +}: { + offer: UserOffer; + isActive: boolean; + onSelect: () => void; +}): ReactElement => ( + +); + +export const StreakOfferCarousel = ({ + currentStreak, + offers, + claimedUids, + onClaim, + onDecline, + onVisible, +}: { + currentStreak: number; + offers: UserOffer[]; + claimedUids: Set; + onClaim: (offer: UserOffer) => void; + onDecline: () => void; + /** Only the centred card is visible, so delivery is reported per card. */ + onVisible: (offers: UserOffer[]) => void; +}): ReactElement => { + const [index, setIndex] = useState(0); + const [drag, setDrag] = useState(0); + const startX = useRef(null); + // The travelled distance lives in a ref as well as state: state drives the + // visual offset, but a fast flick can end before React re-renders, and the + // release has to know how far the finger actually went. + const travelled = useRef(0); + // On touch, a drag that ends on a card is followed by that card's click — + // without suppression the click re-selects the old card and the swipe + // snaps back. + const suppressClick = useRef(false); + const active = offers[index]; + const isClaimed = active && claimedUids.has(active.impressionUid); + + useEffect(() => { + if (active) { + onVisible([active]); + } + }, [active, onVisible]); + + const onPointerDown = useCallback((event: React.PointerEvent) => { + startX.current = event.clientX; + travelled.current = 0; + suppressClick.current = false; + }, []); + + const onPointerMove = useCallback((event: React.PointerEvent) => { + if (startX.current === null) { + return; + } + + travelled.current = event.clientX - startX.current; + setDrag(travelled.current); + }, []); + + const onPointerUp = useCallback(() => { + if (startX.current === null) { + return; + } + + const distance = travelled.current; + + startX.current = null; + travelled.current = 0; + setDrag(0); + + suppressClick.current = Math.abs(distance) > SWIPE_THRESHOLD; + + if (distance < -SWIPE_THRESHOLD) { + setIndex((current) => Math.min(current + 1, offers.length - 1)); + } else if (distance > SWIPE_THRESHOLD) { + setIndex((current) => Math.max(current - 1, 0)); + } + }, [offers.length]); + + return ( +
+ + + + +
+ {/* The track slides rather than scrolls, so the centred card is always + the one the claim button acts on. */} +
+
+ {offers.map((offer, cardIndex) => ( + { + if (suppressClick.current) { + suppressClick.current = false; + return; + } + setIndex(cardIndex); + }} + /> + ))} +
+
+ + {offers.length > 1 && ( +
+ {offers.map((offer, dotIndex) => ( +
+ )} + +
+ + {isClaimed ? ( + + {`${active.advertiserName} gift claimed`} + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx b/packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx new file mode 100644 index 00000000000..2ea5d40b5b7 --- /dev/null +++ b/packages/shared/src/components/streak/offers/StreakOfferCelebration.tsx @@ -0,0 +1,144 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + milestoneForStreak, + streakTierArt, + streakWeekDays, +} from './streakTiers'; + +// The celebration half of the moment, per the milestone-rewards design +// exploration: everything here belongs to daily.dev, no partner paint. +// Gradients and glows are the design's own values; they intentionally bypass +// theme tokens the same way brand paint does (see EngagementAdCta). + +const panelBackground = + 'radial-gradient(120% 100% at 20% 0%, rgba(236,82,122,0.38) 0%, rgba(236,82,122,0.22) 42%, rgba(15,18,24,0) 78%), linear-gradient(160deg, rgba(177,75,215,0.18) 0%, rgba(15,18,24,0) 60%)'; + +const badgeGlow = + 'radial-gradient(circle, rgba(236,82,122,0.55) 0%, rgba(177,75,215,0.25) 45%, transparent 70%)'; + +export const FlameBadge = ({ + tier, + label, + className, +}: { + tier: string; + label: string; + className?: string; +}): ReactElement => ( +
+ + {`${label} +
+); + +const TierName = ({ label }: { label: string }): ReactElement => ( + + {label} + +); + +const DayStrip = ({ className }: { className?: string }): ReactElement => ( +
+ {streakWeekDays.map((label, index) => ( + + {label} + + ))} +
+); + +/** The split popup's left panel: flame, tier, count, headline, week strip. */ +export const StreakOfferCelebration = ({ + currentStreak, + className, +}: { + currentStreak: number; + className?: string; +}): ReactElement => { + const milestone = milestoneForStreak(currentStreak); + + return ( +
+ + +
+
+ + {currentStreak} + + day streak +
+

{milestone.headline}

+
+ +
+ ); +}; + +/** The carousel's compact header: small flame and the count on one line. */ +export const StreakOfferCelebrationCompact = ({ + currentStreak, + children, + className, +}: { + currentStreak: number; + children?: ReactNode; + className?: string; +}): ReactElement => { + const milestone = milestoneForStreak(currentStreak); + + return ( +
+
+ + + + {currentStreak} + + day streak + +
+ {children} +
+ ); +}; diff --git a/packages/shared/src/components/streak/offers/StreakOfferSplit.tsx b/packages/shared/src/components/streak/offers/StreakOfferSplit.tsx new file mode 100644 index 00000000000..de839236391 --- /dev/null +++ b/packages/shared/src/components/streak/offers/StreakOfferSplit.tsx @@ -0,0 +1,103 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import type { UserOffer } from '../../../graphql/offers'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + ClaimedChip, + FinePrint, + GiftHeadline, + OfferLogo, + offerBadgeLabels, +} from './common'; +import { StreakOfferCelebration } from './StreakOfferCelebration'; + +const OfferListRow = ({ + offer, + isClaimed, + onClaim, +}: { + offer: UserOffer; + isClaimed: boolean; + onClaim: (offer: UserOffer) => void; +}): ReactElement => ( +
+ +
+ {offer.title} + + {[ + offer.advertiserName, + offer.perk ?? + offer.description ?? + (offer.badgeLabel && offerBadgeLabels[offer.badgeLabel]), + ] + .filter(Boolean) + .join(' · ')} + +
+ {isClaimed ? ( + + Claimed + + ) : ( + + )} +
+); + +export const StreakOfferSplit = ({ + currentStreak, + offers, + claimedUids, + onClaim, + onVisible, +}: { + currentStreak: number; + offers: UserOffer[]; + claimedUids: Set; + onClaim: (offer: UserOffer) => void; + /** All rows render at once, so every offer counts as delivered on mount. */ + onVisible: (offers: UserOffer[]) => void; +}): ReactElement => { + const reportedVisible = useRef(false); + + useEffect(() => { + if (reportedVisible.current) { + return; + } + + reportedVisible.current = true; + onVisible(offers); + }, [offers, onVisible]); + + return ( +
+ + +
+ +
+ {offers.map((offer) => ( + + ))} +
+ +
+
+ ); +}; diff --git a/packages/shared/src/components/streak/offers/common.tsx b/packages/shared/src/components/streak/offers/common.tsx new file mode 100644 index 00000000000..b47d24534bd --- /dev/null +++ b/packages/shared/src/components/streak/offers/common.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { UserOffer } from '../../../graphql/offers'; +import { VIcon } from '../../icons'; +import { IconSize } from '../../Icon'; + +export const offerBadgeLabels: Record< + NonNullable, + string +> = { + free_trial: 'Free trial', + discount: 'Discount', +}; + +export const OfferLogo = ({ + offer, + className, +}: { + offer: UserOffer; + className?: string; +}): ReactElement => { + const src = offer.advertiserLogo || offer.imageUrl; + + if (!src) { + return ( + + {offer.advertiserName.charAt(0)} + + ); + } + + return ( + {`${offer.advertiserName} + ); +}; + +export const GiftHeadline = ({ + count, + centered, + className, +}: { + count: number; + centered?: boolean; + className?: string; +}): ReactElement => ( +
+

+ Here's a little{' '} + gift from us +

+

+ {count > 1 + ? 'Choose one of our partner offers below' + : 'A partner offer, on your streak'} +

+
+); + +export const FinePrint = ({ + className, +}: { + className?: string; +}): ReactElement => ( +

+ Sponsored offers. No charge until a trial ends, cancel anytime. +

+); + +export const ClaimedChip = ({ + children, + className, +}: { + children: string; + className?: string; +}): ReactElement => ( + + + {children} + +); diff --git a/packages/shared/src/components/streak/offers/streakTiers.ts b/packages/shared/src/components/streak/offers/streakTiers.ts new file mode 100644 index 00000000000..fc02197cb91 --- /dev/null +++ b/packages/shared/src/components/streak/offers/streakTiers.ts @@ -0,0 +1,64 @@ +// Streak tier ladder from the milestone-rewards design exploration (#6486), +// itself modeled on the streak progression system (#5613). Until that system +// ships, the popup derives the tier from the highest ladder step at or below +// the current streak. + +export type StreakTierMilestone = { + day: number; + tier: string; + label: string; + headline: string; +}; + +const streakTierLadder: StreakTierMilestone[] = [ + { day: 3, tier: 'spark', label: 'Spark', headline: 'Three days in a row' }, + { day: 5, tier: 'kindle', label: 'Kindle', headline: 'Five days in a row' }, + { day: 7, tier: 'flame', label: 'Flame', headline: 'A full week, unbroken' }, + { day: 14, tier: 'blaze', label: 'Blaze', headline: 'Two weeks straight' }, + { + day: 21, + tier: 'firestorm', + label: 'Firestorm', + headline: 'Twenty one days', + }, + { + day: 30, + tier: 'inferno', + label: 'Inferno', + headline: 'A full month, unbroken', + }, + { day: 60, tier: 'scorcher', label: 'Scorcher', headline: 'Sixty days' }, + { + day: 90, + tier: 'eternal-flame', + label: 'Eternal Flame', + headline: 'Ninety days', + }, + { day: 180, tier: 'supernova', label: 'Supernova', headline: 'Half a year' }, + { + day: 365, + tier: 'legendary', + label: 'Legendary', + headline: 'One year, every single day', + }, +]; + +export const milestoneForStreak = (day: number): StreakTierMilestone => { + const reached = streakTierLadder.filter((milestone) => milestone.day <= day); + const tier = reached[reached.length - 1] ?? streakTierLadder[0]; + + // The ladder's headlines are written for their exact day ("A full week, + // unbroken" is only true at 7), but the milestone alert fires on the API's + // own schedule (Fibonacci days like 2, 8, 13) — any other day keeps the + // tier art/label (tiers are ranges) with copy derived from the count. + if (tier.day === day) { + return tier; + } + + return { ...tier, headline: `${day} days in a row` }; +}; + +export const streakTierArt = (tier: string): string => + `https://media.daily.dev/image/upload/f_auto,q_auto/public/streak-tier-${tier}`; + +export const streakWeekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; diff --git a/packages/shared/src/graphql/offers.ts b/packages/shared/src/graphql/offers.ts new file mode 100644 index 00000000000..56d61bae993 --- /dev/null +++ b/packages/shared/src/graphql/offers.ts @@ -0,0 +1,81 @@ +import { gql } from 'graphql-request'; +import type { LoggedUser } from '../lib/user'; +import { generateQueryKey, RequestKey } from '../lib/query'; +import { gqlClient } from './common'; + +export enum OfferPlacement { + StreakMilestone = 'STREAK_MILESTONE', +} + +export type UserOffer = { + impressionUid: string; + clickUrl: string; + title: string; + description?: string; + imageUrl?: string; + advertiserName: string; + advertiserLogo?: string; + perk?: string; + badgeLabel?: 'free_trial' | 'discount'; +}; + +export const USER_OFFERS_QUERY = gql` + query UserOffers($placement: OfferPlacement!) { + userOffers(placement: $placement) { + impressionUid + clickUrl + title + description + imageUrl + advertiserName + advertiserLogo + perk + badgeLabel + } + } +`; + +export const CONFIRM_OFFERS_DELIVERED_MUTATION = gql` + mutation ConfirmOffersDelivered($impressionUids: [ID!]!) { + confirmOffersDelivered(impressionUids: $impressionUids) { + _ + } + } +`; + +export const getUserOffers = async ( + placement: OfferPlacement, +): Promise => { + const result = await gqlClient.request<{ userOffers: UserOffer[] }>( + USER_OFFERS_QUERY, + { placement }, + ); + + return result.userOffers; +}; + +export const confirmOffersDelivered = ( + impressionUids: string[], +): Promise => + gqlClient.request(CONFIRM_OFFERS_DELIVERED_MUTATION, { impressionUids }); + +export const userOffersQueryOptions = ({ + user, + placement, +}: { + user: Pick | undefined | null; + placement: OfferPlacement; +}) => ({ + queryKey: generateQueryKey( + RequestKey.UserOffers, + user ?? undefined, + placement, + ), + queryFn: () => getUserOffers(placement), + // Offer click links are tokenized and expire server-side, so offers must + // never be cached or reused across moments. + staleTime: 0, + gcTime: 0, + retry: false, + refetchOnWindowFocus: false, +}); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 9de4d012947..fc30dfecd5b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -117,6 +117,14 @@ export const featureCores = new Feature('cores', isDevelopment); // automated streak freeze: auto-apply purchased freezes on missed reading days export const featureStreakFreeze = new Feature('streak_freeze', isDevelopment); +// Experiment: sponsored partner offers (via Encore) replacing the classic +// streak milestone popup. Enrollment is conditional on the popup actually +// showing; treatment falls back to the classic popup when no offers return. +export const featureStreakMilestoneOffers = new Feature( + 'streak_milestone_offers', + isDevelopment, +); + // whether the user will see post boost ads // does not necessarily mean they can't boost a post if they have access to cores export const featurePostBoostAds = new Feature('post_boost_ads', isDevelopment); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index a5b06978fa9..ce043e54aba 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -242,6 +242,7 @@ export enum LogEvent { // Reading Streaks OpenStreaks = 'open streaks', DismissStreaksMilestone = 'dismiss streaks milestone', + DismissStreakOffers = 'dismiss streak offers', ScheduleStreakReminder = 'schedule streak reminder', StreakRecover = 'restore streak', DismissStreakRecover = 'dimiss streaks milestone', @@ -549,6 +550,7 @@ export enum TargetType { VerifyEmail = 'verify email', ResendVerificationCode = 'resend verification code', StreaksMilestone = 'streaks milestone', + StreakOffer = 'streak offer', StreakRecover = 'streak restore', StreakFreezePurchase = 'streak freeze purchase', PromotionCard = 'promotion_card', diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index c3eef38495d..adeeafe4ce3 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -145,6 +145,7 @@ export enum RequestKey { StreakFreezeProducts = 'streak_freeze_products', StreakFreezeDates = 'streak_freeze_dates', StreakFreezePurchase = 'streak_freeze_purchase', + UserOffers = 'user_offers', PersonalizedDigest = 'personalizedDigest', Changelog = 'changelog', Tags = 'tags', From a028c9ca8d2663f283245595e58c4bb9bf78566f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 12:36:06 +0300 Subject: [PATCH 37/67] feat(game-center): claim streak offers from the milestone rail Sponsored offers now render as cards trailing the milestone quests, reusing the offer primitives from #6487 so the claim contract matches the popup: render-then-confirm on the impression, then open the tokenized click URL and swap the button for a claimed chip. They trail the quests deliberately, so a sponsored card never sits ahead of one the user earned, and each card carries its own "Sponsored" label since the rail has no shared disclosure line. The game center spec now stubs useMutation: it renders the page without a QueryClientProvider, and the offer cards mutate on mount. Co-Authored-By: Claude Opus 5 --- .../__tests__/GameCenterStaticProps.spec.ts | 3 + .../game-center/MilestoneOffers.tsx | 178 ++++++++++++++++++ .../game-center/MilestoneQuestList.tsx | 6 +- packages/webapp/pages/game-center/index.tsx | 4 + 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 packages/webapp/components/game-center/MilestoneOffers.tsx diff --git a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index 96c6825df95..aa52c44fa76 100644 --- a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts +++ b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts @@ -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 })), }; }); diff --git a/packages/webapp/components/game-center/MilestoneOffers.tsx b/packages/webapp/components/game-center/MilestoneOffers.tsx new file mode 100644 index 00000000000..4ee09a09666 --- /dev/null +++ b/packages/webapp/components/game-center/MilestoneOffers.tsx @@ -0,0 +1,178 @@ +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'; + +const OfferCard = ({ + offer, + isClaimed, + onClaim, +}: { + offer: UserOffer; + isClaimed: boolean; + onClaim: (offer: UserOffer) => void; +}): ReactElement => { + const subtitle = + offer.perk ?? + offer.description ?? + (offer.badgeLabel && offerBadgeLabels[offer.badgeLabel]); + + return ( +
+
+ + + Sponsored + +
+ + + {offer.title} + + + {subtitle && ( + + {subtitle} + + )} + +
+ {isClaimed ? ( + + Claimed + + ) : ( + + )} +
+
+ ); +}; + +type MilestoneOffersProps = { + currentStreak: number; +}; + +export const MilestoneOffers = ({ + currentStreak, +}: MilestoneOffersProps): 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], + ); + + if (!offers.length) { + return null; + } + + return ( + <> + {offers.map((offer) => ( + + ))} + + ); +}; diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index 16e25ff7825..9845391c10f 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { @@ -198,6 +198,8 @@ type MilestoneQuestListProps = { showLevelSystem: boolean; claimingQuestId?: string; onClaim: (userQuestId: string, questId: string, questType: QuestType) => void; + /** Trails the quests, so sponsored cards never sit ahead of earned ones. */ + trailing?: ReactNode; }; export const MilestoneQuestList = ({ @@ -205,6 +207,7 @@ export const MilestoneQuestList = ({ showLevelSystem, claimingQuestId, onClaim, + trailing, }: MilestoneQuestListProps): ReactElement => { const ordered = sortMilestoneQuests(quests); @@ -219,6 +222,7 @@ export const MilestoneQuestList = ({ onClaim={onClaim} /> ))} + {trailing}
); }; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 85b507ed0b0..012d9d68dd0 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -78,6 +78,7 @@ import { import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; +import { MilestoneOffers } from '../../components/game-center/MilestoneOffers'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; import { SeeAllAchievementsCard } from '../../components/game-center/SeeAllAchievementsCard'; import { CommunityPulse } from '../../components/game-center/CommunityPulse'; @@ -358,6 +359,9 @@ function GameCenterPage({ showLevelSystem={showLevelSystem} claimingQuestId={claimingMilestoneQuestId} onClaim={handleMilestoneClaim} + trailing={ + + } /> ); } else { From 91a2e8f6b6036ff751445280f521481fa97d6bd5 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 12:38:08 +0300 Subject: [PATCH 38/67] feat(storybook): three Badges & Trophies combined-section designs Previews for merging the badge case and trophy case into one section: a split holder, a single unified wall, and a tabbed case. Nothing on the page changes yet; these exist to pick from. Co-Authored-By: Claude Opus 5 --- .../pages/BadgeTrophyDesigns.stories.tsx | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 packages/storybook/stories/pages/BadgeTrophyDesigns.stories.tsx 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: () => ( +
+ + + +
+ ), +}; From 7425bed246f139fa5ebd8561eccd5f0e757a780f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 13:28:39 +0300 Subject: [PATCH 39/67] feat(game-center): merge the badge and trophy cases into one section Design 1, the split holder: badges as rows down a 320px left pane, awards in the grid on the right, one hairline between them and both counts on a single "Badges & Trophies" header. Badges change shape to fit the pane. TopReaderBadgeCompact was a horizontal rail of cards, which cannot live in a 320px column, so they become rows carrying the same topic, date, and gold chip, and the column scrolls past eight or so. TrophyGrid loses the holder it grew two commits ago. The pane is the holder now, and keeping both drew a box inside a box. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 82 ++++++++++------- .../game-center/BadgeTrophyCase.tsx | 88 +++++++++++++++++++ .../components/game-center/TrophyGrid.tsx | 18 ++-- packages/webapp/pages/game-center/index.tsx | 47 +++++----- 4 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 packages/webapp/components/game-center/BadgeTrophyCase.tsx diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index cd04c84f780..2e204e318ce 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -20,11 +20,18 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; -import { CoreIcon } from '@dailydotdev/shared/src/components/icons'; +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 { + BadgeRow, + BadgeTrophyCase, +} from '../../../webapp/components/game-center/BadgeTrophyCase'; import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; const SectionHeader = ({ title }: { title: string }) => ( @@ -393,39 +400,52 @@ const GameCenterRedesign = () => (
- -
-
- {badges.map((badge) => ( -
- + +
+ -
- ))} + } + className={{ + container: '!flex-row items-center gap-2 !border-0 !p-0', + label: '!typo-subhead', + }} + /> + + } + className={{ + container: '!flex-row items-center gap-2 !border-0 !p-0', + label: '!typo-subhead', + }} + />
-
- - -
-
- - - } - className={{ - container: '!flex-row items-center gap-2 !border-0 !p-0', - label: '!typo-subhead', - }} - /> -
- + + {badges.map((badge) => ( + + ))} +
+ } + awards={} + />
diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx new file mode 100644 index 00000000000..a4df9deb7bc --- /dev/null +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -0,0 +1,88 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + DevCardTheme, + themeToLinearGradient, +} from '@dailydotdev/shared/src/components/profile/devcard'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import type { TopReader } from '@dailydotdev/shared/src/components/badges/TopReaderBadge'; +import { + formatDate, + TimeFormatType, +} from '@dailydotdev/shared/src/lib/dateFormat'; + +export const BadgeRow = ({ + issuedAt, + keyword, +}: Pick): ReactElement => { + return ( +
+
+ + {keyword.flags?.title || keyword.value} + + + {formatDate({ + value: issuedAt, + type: TimeFormatType.TopReaderBadge, + })} + +
+ + + Top reader + + +
+ ); +}; + +const Pane = ({ + title, + children, +}: { + title: string; + children: ReactNode; +}): ReactElement => ( +
+ + {title} + + {children} +
+); + +type BadgeTrophyCaseProps = { + badges: ReactNode; + awards: ReactNode; +}; + +export const BadgeTrophyCase = ({ + badges, + awards, +}: BadgeTrophyCaseProps): ReactElement => { + return ( +
+ {badges} + {awards} +
+ ); +}; diff --git a/packages/webapp/components/game-center/TrophyGrid.tsx b/packages/webapp/components/game-center/TrophyGrid.tsx index 78c6aa4b146..5e261191408 100644 --- a/packages/webapp/components/game-center/TrophyGrid.tsx +++ b/packages/webapp/components/game-center/TrophyGrid.tsx @@ -48,16 +48,14 @@ const Cell = ({ award }: { award: AwardWithRarity }): ReactElement => { export const TrophyGrid = ({ awards }: TrophyGridProps): ReactElement => { return ( -
-
- {awards.map((award) => ( - - ))} -
+
+ {awards.map((award) => ( + + ))}
); }; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 012d9d68dd0..983e9cba4ce 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -64,7 +64,6 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { AchievementShelfCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementShelfCard'; -import { TopReaderBadgeCompact } from '@dailydotdev/shared/src/components/badges/TopReaderBadgeCompact'; 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'; @@ -78,6 +77,10 @@ import { import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; +import { + BadgeRow, + BadgeTrophyCase, +} from '../../components/game-center/BadgeTrophyCase'; import { MilestoneOffers } from '../../components/game-center/MilestoneOffers'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; import { SeeAllAchievementsCard } from '../../components/game-center/SeeAllAchievementsCard'; @@ -452,17 +455,14 @@ function GameCenterPage({ ); } else if (topReaderBadges.length > 0) { badgeCaseContent = ( -
-
- {topReaderBadges.map((badge) => ( -
- -
- ))} -
+
+ {topReaderBadges.map((badge) => ( + + ))}
); } else { @@ -793,15 +793,22 @@ function GameCenterPage({ )}
- - - {badgeCaseContent} -
- -
- + + {badgeTopics} + {trophyTotal} +
+ ) + } + /> - {trophyCaseContent} +
From a154392812d7a771519c1e3703b3f556fa8c9f89 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 13:39:39 +0300 Subject: [PATCH 40/67] feat(game-center): lead each case pane with its count The panes were titled "Top reader badges" and "Awards" in small tertiary text, with the numbers sitting away in the section header. Each pane now leads with its count and captions it with the label, matching the community pulse counters. The header tiles go, since they were the same two numbers: keeping both would have printed "Topics mastered 4" twice in one section. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 35 ++---------- .../game-center/BadgeTrophyCase.tsx | 39 ++++++++++---- packages/webapp/pages/game-center/index.tsx | 54 ++++--------------- 3 files changed, 41 insertions(+), 87 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 2e204e318ce..3ccbb868297 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -400,38 +400,7 @@ const GameCenterRedesign = () => (
-
- -
- - } - className={{ - container: '!flex-row items-center gap-2 !border-0 !p-0', - label: '!typo-subhead', - }} - /> - - } - className={{ - container: '!flex-row items-center gap-2 !border-0 !p-0', - label: '!typo-subhead', - }} - /> -
-
+ @@ -444,7 +413,9 @@ const GameCenterRedesign = () => ( ))}
} + badgeCount={badges.length.toString()} awards={} + awardCount="87" /> diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index a4df9deb7bc..84644a3b00e 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -51,38 +51,55 @@ export const BadgeRow = ({ ); }; +// The count leads and the label captions it, matching the community pulse +// counters. const Pane = ({ - title, + value, + label, children, }: { - title: string; + value: string; + label: string; children: ReactNode; }): ReactElement => (
- - {title} - +
+ + {value} + + + {label} + +
{children}
); type BadgeTrophyCaseProps = { badges: ReactNode; + badgeCount: string; awards: ReactNode; + awardCount: string; }; export const BadgeTrophyCase = ({ badges, + badgeCount, awards, + awardCount, }: BadgeTrophyCaseProps): ReactElement => { return (
- {badges} - {awards} + + {badges} + + + {awards} +
); }; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 983e9cba4ce..33e9db7b3de 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -33,6 +33,7 @@ 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, @@ -70,7 +71,6 @@ import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/L import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ArrowIcon, - CoreIcon, MedalBadgeIcon, PinIcon, } from '@dailydotdev/shared/src/components/icons'; @@ -425,24 +425,9 @@ function GameCenterPage({ ); } - const badgeTopics = - !isBadgesPending && topReaderBadges.length > 0 ? ( - - } - className={{ - container: '!flex-row items-center gap-2 !border-0 !p-0', - label: '!typo-subhead', - }} - /> - ) : undefined; + const badgeCountLabel = isBadgesPending + ? '...' + : formatDataTileValue(getBadgeSummary(topReaderBadges).uniqueTopics); let badgeCaseContent: ReactElement; @@ -479,20 +464,9 @@ function GameCenterPage({ !isAwardsPending && !awardsError && awardSummary.awards.length > 0; - // Laid out horizontally so it reads as one line beside the section title - // rather than as a stacked tile. - const trophyTotal = hasAwards ? ( - } - className={{ - container: '!flex-row items-center gap-2 !border-0 !p-0', - label: '!typo-subhead', - }} - /> - ) : undefined; + const awardCountLabel = hasAwards + ? formatDataTileValue(awardSummary.totalAwards) + : '0'; let trophyCaseContent: ReactElement; @@ -793,21 +767,13 @@ function GameCenterPage({ )}
- - {badgeTopics} - {trophyTotal} -
- ) - } - /> + From 0dca9830a0633ba992a1281c4d632d670aa58707 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 13:41:22 +0300 Subject: [PATCH 41/67] feat(game-center): style the case panes like the progress snapshot The section was a bordered holder with hairline-divided panes. It now follows the snapshot: no frame, no dividers, just two filled cells at rounded-14 on background-subtle with an 8px gap, so the fills do the separating. The badge rows flip to background-default. They were background-subtle, which the pane itself now uses, and same-on-same would have erased them. Co-Authored-By: Claude Opus 5 --- packages/webapp/components/game-center/BadgeTrophyCase.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index 84644a3b00e..a1839118632 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -20,7 +20,7 @@ export const BadgeRow = ({ keyword, }: Pick): ReactElement => { return ( -
+
{keyword.flags?.title || keyword.value} @@ -62,7 +62,7 @@ const Pane = ({ label: string; children: ReactNode; }): ReactElement => ( -
+
{value} @@ -93,7 +93,7 @@ export const BadgeTrophyCase = ({ awardCount, }: BadgeTrophyCaseProps): ReactElement => { return ( -
+
{badges} From 11bdb541a5d5ead30e48ae8a61c6ce5f359a9878 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 13:58:35 +0300 Subject: [PATCH 42/67] feat(game-center): badge artwork, wider awards, offer subtitles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badges get an image on the left, using the artwork the badge query already returns. Keywords carry no image of their own, so where a badge has none the row falls back to the topic's initial. Awards go from twelve narrow columns to five across a row, with the art up from 48px to 80px. Offer subtitles now read "advertiser · perk", matching the popup rows instead of showing the perk alone. The story gains three mock offers so the rail can be seen with the sponsored cards in place; OfferCard is exported for it. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 42 +++++++++++++++++++ .../game-center/BadgeTrophyCase.tsx | 25 +++++++++-- .../game-center/MilestoneOffers.tsx | 12 ++++-- .../components/game-center/TrophyGrid.tsx | 4 +- packages/webapp/pages/game-center/index.tsx | 1 + 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 3ccbb868297..5cb4e62f028 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -26,6 +26,7 @@ import { } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; +import { OfferCard } from '../../../webapp/components/game-center/MilestoneOffers'; import { CommunityPulse } from '../../../webapp/components/game-center/CommunityPulse'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import { @@ -235,6 +236,7 @@ const badges = [ { issuedAt: new Date('2026-06-01'), keyword: { value: 'clickhouse', flags: { title: 'ClickHouse' } }, + image: undefined as string | undefined, }, { issuedAt: new Date('2026-05-01'), @@ -250,6 +252,33 @@ const badges = [ }, ]; +const offers = [ + { + impressionUid: 'offer-disney', + clickUrl: '#', + title: 'Disney+ for $4.99/mo for 3 months', + advertiserName: 'Disney+', + perk: 'Unlimited entertainment', + badgeLabel: 'discount' as const, + }, + { + impressionUid: 'offer-hulu', + clickUrl: '#', + title: '30 days free trial on Hulu', + advertiserName: 'Hulu', + perk: 'Movies, shows & live TV', + badgeLabel: 'free_trial' as const, + }, + { + impressionUid: 'offer-notion', + clickUrl: '#', + title: '3 months of Notion Business, free', + advertiserName: 'Notion', + perk: 'Notes, Tasks, AI', + badgeLabel: 'free_trial' as const, + }, +]; + const awards: AwardWithRarity[] = [ { id: 'diamond', name: 'Diamond', image: '', count: 1, value: 5000 }, { id: 'crown', name: 'Crown', image: '', count: 2, value: 2000 }, @@ -375,6 +404,18 @@ const GameCenterRedesign = () => ( quests={milestoneQuests} showLevelSystem onClaim={() => undefined} + trailing={ + <> + {offers.map((offer) => ( + undefined} + /> + ))} + + } /> @@ -409,6 +450,7 @@ const GameCenterRedesign = () => ( key={badge.keyword.value} issuedAt={badge.issuedAt} keyword={badge.keyword} + image={badge.image} /> ))}
diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index a1839118632..4f429019802 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -9,6 +9,7 @@ import { 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, @@ -18,12 +19,28 @@ import { export const BadgeRow = ({ issuedAt, keyword, -}: Pick): ReactElement => { + 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()} + + )} +
- {keyword.flags?.title || keyword.value} + {title} void; }): ReactElement => { - const subtitle = + const subtitle = [ + offer.advertiserName, offer.perk ?? - offer.description ?? - (offer.badgeLabel && offerBadgeLabels[offer.badgeLabel]); + offer.description ?? + (offer.badgeLabel && offerBadgeLabels[offer.badgeLabel]), + ] + .filter(Boolean) + .join(' · '); return (
diff --git a/packages/webapp/components/game-center/TrophyGrid.tsx b/packages/webapp/components/game-center/TrophyGrid.tsx index 5e261191408..991ae3b55ea 100644 --- a/packages/webapp/components/game-center/TrophyGrid.tsx +++ b/packages/webapp/components/game-center/TrophyGrid.tsx @@ -26,7 +26,7 @@ const Cell = ({ award }: { award: AwardWithRarity }): ReactElement => { alt={award.name} fallbackSrc={featuredAwardImage} loading="lazy" - className="size-12 object-contain drop-shadow-[0_8px_12px_rgba(0,0,0,0.4)] transition-transform group-hover:scale-105" + className="size-20 object-contain drop-shadow-[0_8px_12px_rgba(0,0,0,0.4)] transition-transform group-hover:scale-105" /> { export const TrophyGrid = ({ awards }: TrophyGridProps): ReactElement => { return (
diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 33e9db7b3de..a47fc62db95 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -446,6 +446,7 @@ function GameCenterPage({ key={badge.id} issuedAt={badge.issuedAt} keyword={badge.keyword} + image={badge.image} /> ))}
From 8f4a76ea7baa79b9d92ca40907d1928f47614ba9 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 14:15:06 +0300 Subject: [PATCH 43/67] feat(game-center): white level badge, bigger achievement cards The level badge fills white with the number in accent-cabbage, and gains 8px below it so the bar is not crowded against it. The number goes to weight 900, which needs !font-black: Typography's bold prop emits font-bold and wins on emission order otherwise. Achievement cards go from 192x252 to 208x272. Also adds five new community pulse directions in Storybook, under Pages/Community Pulse Ideas. Nothing on the page changes. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 4 +- .../achievements/AchievementShelfCard.tsx | 2 +- .../pages/CommunityPulseIdeas.stories.tsx | 588 ++++++++++++++++++ 3 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 packages/storybook/stories/pages/CommunityPulseIdeas.stories.tsx diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index a84e85b1575..b7b00084770 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -146,7 +146,7 @@ export const LevelHud = ({ {/* The number stands alone, so the chip carries the meaning for screen readers. */}
{level} diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index ad5d076c6a1..75765a19f06 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -93,7 +93,7 @@ export function AchievementShelfCard({ return ( <> -
+
{/* `absolute` has to come from the prop: LazyImage appends its own `relative` after our classes, and that wins in the compiled CSS. */} + 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: () => ( +
+ + + + + +
+ ), +}; From e8f6eea3189fa890fdf17c5226cf751f1abf5419 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 14:41:39 +0300 Subject: [PATCH 44/67] feat(game-center): one icon colour, framed case section The four snapshot stat icons were bun, cheese, cheese and onion. They all take accent-cabbage now, matching the level number, so the tiles read as one set rather than four unrelated stats. The badges and trophies section gains the snapshot's frame: same 1px border, 20px radius and 8px padding around its two cells. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/quest/LevelHud.tsx | 8 ++++---- .../webapp/components/game-center/BadgeTrophyCase.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index b7b00084770..f8eef41c66a 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -93,7 +93,7 @@ export const LevelHud = ({ ), label: 'Streak', @@ -104,7 +104,7 @@ export const LevelHud = ({ ), label: 'Longest', @@ -116,7 +116,7 @@ export const LevelHud = ({ icon: ( ), label: 'Badges', @@ -129,7 +129,7 @@ export const LevelHud = ({ ), label: 'Total XP', diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index 4f429019802..4d844892393 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -110,7 +110,7 @@ export const BadgeTrophyCase = ({ awardCount, }: BadgeTrophyCaseProps): ReactElement => { return ( -
+
{badges} From 9b2fb12bb588c4a8a9cd4e7f39325e411c2888eb Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 14:46:11 +0300 Subject: [PATCH 45/67] feat(game-center): give the offers their own Claimable gifts strip The offers trailed the milestone rail, which mixed sponsored cards in with earned ones. They get their own strip under Milestone quests instead, with the popup's fine print on the header row so the sponsorship is disclosed once for the section. MilestoneOffers becomes ClaimableGifts, split into a connected component and a presentational section so the story can render it without the query. The rail's trailing slot goes with it, having no consumer left. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 21 +++---- ...MilestoneOffers.tsx => ClaimableGifts.tsx} | 63 +++++++++++++++---- .../game-center/MilestoneQuestList.tsx | 6 +- packages/webapp/pages/game-center/index.tsx | 7 +-- 4 files changed, 62 insertions(+), 35 deletions(-) rename packages/webapp/components/game-center/{MilestoneOffers.tsx => ClaimableGifts.tsx} (79%) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 5cb4e62f028..b7085217581 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -26,7 +26,7 @@ import { } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; -import { OfferCard } from '../../../webapp/components/game-center/MilestoneOffers'; +import { ClaimableGiftsSection } from '../../../webapp/components/game-center/ClaimableGifts'; import { CommunityPulse } from '../../../webapp/components/game-center/CommunityPulse'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import { @@ -404,21 +404,16 @@ const GameCenterRedesign = () => ( quests={milestoneQuests} showLevelSystem onClaim={() => undefined} - trailing={ - <> - {offers.map((offer) => ( - undefined} - /> - ))} - - } + /> + undefined} + /> + diff --git a/packages/webapp/components/game-center/MilestoneOffers.tsx b/packages/webapp/components/game-center/ClaimableGifts.tsx similarity index 79% rename from packages/webapp/components/game-center/MilestoneOffers.tsx rename to packages/webapp/components/game-center/ClaimableGifts.tsx index f5ac4cd0e65..81c33fdddc8 100644 --- a/packages/webapp/components/game-center/MilestoneOffers.tsx +++ b/packages/webapp/components/game-center/ClaimableGifts.tsx @@ -96,13 +96,54 @@ export const OfferCard = ({ ); }; -type MilestoneOffersProps = { +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 MilestoneOffers = ({ +export const ClaimableGifts = ({ currentStreak, -}: MilestoneOffersProps): ReactElement | null => { +}: ClaimableGiftsProps): ReactElement | null => { const { user } = useAuthContext(); const { logEvent } = useLogContext(); const [claimedUids, setClaimedUids] = useState>(new Set()); @@ -163,20 +204,16 @@ export const MilestoneOffers = ({ [currentStreak, logEvent], ); + // No offers means no section at all, header included. if (!offers.length) { return null; } return ( - <> - {offers.map((offer) => ( - - ))} - + ); }; diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index 9845391c10f..16e25ff7825 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import { @@ -198,8 +198,6 @@ type MilestoneQuestListProps = { showLevelSystem: boolean; claimingQuestId?: string; onClaim: (userQuestId: string, questId: string, questType: QuestType) => void; - /** Trails the quests, so sponsored cards never sit ahead of earned ones. */ - trailing?: ReactNode; }; export const MilestoneQuestList = ({ @@ -207,7 +205,6 @@ export const MilestoneQuestList = ({ showLevelSystem, claimingQuestId, onClaim, - trailing, }: MilestoneQuestListProps): ReactElement => { const ordered = sortMilestoneQuests(quests); @@ -222,7 +219,6 @@ export const MilestoneQuestList = ({ onClaim={onClaim} /> ))} - {trailing}
); }; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index a47fc62db95..7d8e55273d3 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -81,7 +81,7 @@ import { BadgeRow, BadgeTrophyCase, } from '../../components/game-center/BadgeTrophyCase'; -import { MilestoneOffers } from '../../components/game-center/MilestoneOffers'; +import { ClaimableGifts } from '../../components/game-center/ClaimableGifts'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; import { SeeAllAchievementsCard } from '../../components/game-center/SeeAllAchievementsCard'; import { CommunityPulse } from '../../components/game-center/CommunityPulse'; @@ -362,9 +362,6 @@ function GameCenterPage({ showLevelSystem={showLevelSystem} claimingQuestId={claimingMilestoneQuestId} onClaim={handleMilestoneClaim} - trailing={ - - } /> ); } else { @@ -741,6 +738,8 @@ function GameCenterPage({ {milestoneQuestContent} + + {showAchievements && ( <>
From 194ece2ea1e42b8737c471e188fe104ed0936e67 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 14:57:09 +0300 Subject: [PATCH 46/67] feat(game-center): lay the milestones out as a grid The quests were a horizontal scroller, which hid the claimed one past the right edge and made the ordering impossible to read. They now sit in a grid: two columns from tablet, four on laptop, so eight fit above the fold without scrolling. The cards drop their fixed 240px width and shrink-0 to fill their column. Rows size independently, so a row of taller cards does not pad out the rest. Co-Authored-By: Claude Opus 5 --- packages/webapp/components/game-center/MilestoneQuestList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index 16e25ff7825..653cdb8cfb8 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -89,7 +89,7 @@ const MilestoneQuestCard = ({ return (
+
{ordered.map((quest) => ( Date: Mon, 24 Aug 2026 15:01:49 +0300 Subject: [PATCH 47/67] feat(game-center): split the case section evenly The badge pane was capped at 20rem, which left the awards pane nearly three times its width. Both take half now. Co-Authored-By: Claude Opus 5 --- packages/webapp/components/game-center/BadgeTrophyCase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index 4d844892393..8f7d23860bb 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -110,7 +110,7 @@ export const BadgeTrophyCase = ({ awardCount, }: BadgeTrophyCaseProps): ReactElement => { return ( -
+
{badges} From 03d9d936d381929e48633944efe1071dc811fa1a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 15:08:24 +0300 Subject: [PATCH 48/67] feat(storybook): real topic logos on the badge rows The badge mocks pointed at nothing, so every row fell back to an initial. Three now use real logos from the sources library, resolved by querying source(id:) for each keyword. The logo path is not derivable from the keyword: ClickHouse sits at logos/clickhouse, React at logos/react_js, and Rust under a UUID. GitHub Actions has no source at all, so it stays on the initial and shows what the fallback looks like. Co-Authored-By: Claude Opus 5 --- .../stories/pages/GameCenterRedesign.stories.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index b7085217581..2c02885ecaa 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -232,15 +232,21 @@ const achievements: UserAchievement[] = [ ), ]; +// 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: undefined as string | undefined, + 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'), @@ -249,6 +255,7 @@ const badges = [ { issuedAt: new Date('2026-03-01'), keyword: { value: 'react', flags: { title: 'React' } }, + image: logo('react_js'), }, ]; From 86afde256b64445b31f04d2b08340b103145d050 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 16:11:59 +0300 Subject: [PATCH 49/67] feat(game-center): rebuild community pulse as two races Idea 4. The section was two avatar rails where rank was carried only by position and a podium ring, so the gap between first and fifth was invisible. It becomes two ranked columns, reputation and quests, each row a bar measured against that column's leader. The comparison is the point: reputation clusters tight at the top (100/97/90/85/74) while quests spread wide (100/64/60/54/47), which the rails gave no way to see. The all-time total stays above the columns. The two leader counters go: they named a single quest each, which the ranked columns cover better. Co-Authored-By: Claude Opus 5 --- .../components/game-center/CommunityPulse.tsx | 237 ++++++++---------- 1 file changed, 105 insertions(+), 132 deletions(-) diff --git a/packages/webapp/components/game-center/CommunityPulse.tsx b/packages/webapp/components/game-center/CommunityPulse.tsx index ac3c9d41305..6ad6796b267 100644 --- a/packages/webapp/components/game-center/CommunityPulse.tsx +++ b/packages/webapp/components/game-center/CommunityPulse.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import React from 'react'; -import classNames from 'classnames'; import type { QuestCompletionStats } from '@dailydotdev/shared/src/graphql/leaderboard'; import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { @@ -13,103 +12,83 @@ import { TypographyColor, 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 { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; -const railLength = 10; - -// Gold, silver, bronze for the first three; everyone else rides plain. -const podiumRing = [ - 'ring-accent-cheese-default', - 'ring-accent-salt-subtle', - 'ring-accent-bun-default', -]; - -const podiumBadge = [ - 'bg-accent-cheese-default', - 'bg-accent-salt-subtle', - 'bg-accent-bun-default', -]; - -type CounterProps = { - value: string; - label: string; - caption?: string; -}; - -const Counter = ({ value, label, caption }: CounterProps): ReactElement => ( -
- - {value} - - - {label} - - {caption && ( - - {caption} - - )} -
-); +const raceLength = 5; -type RailProps = { - label: string; - items: UserLeaderboard[]; +type RaceProps = { + title: string; + icon: ReactElement; + entries: UserLeaderboard[]; unit: string; }; -const Rail = ({ label, items, unit }: RailProps): ReactElement => ( -
- - {label} - -
- {items.slice(0, railLength).map((entry, index) => ( - - - - {index < 3 && ( - - {index + 1} - - )} - - - ))} +const Race = ({ title, icon, entries, unit }: 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 ( +
+
+ {icon} + + {title} + +
+
+ {ranked.map((entry, index) => ( +
+ + {index + 1} + + + + + + +
+
+ + {entry.user.name} + + + {formatDataTileValue(entry.score)} + +
+
+
+
+
+
+ ))} +
-
-); + ); +}; type CommunityPulseProps = { stats: QuestCompletionStats | null; @@ -122,50 +101,44 @@ export const CommunityPulse = ({ highestReputation, mostQuestsCompleted, }: CommunityPulseProps): ReactElement => ( -
+
{stats && ( -
- - {/* The count leads and the quest name captions it — the other way - round the name is what gets truncated, and it is the useful half. */} - {stats.allTimeLeader && ( - - )} - {stats.weeklyLeader && ( - - )} +
+ + {formatDataTileValue(stats.totalCount)} + + + quests completed all-time +
)} - - {(highestReputation.length > 0 || mostQuestsCompleted.length > 0) && ( -
- {highestReputation.length > 0 && ( - + - )} - {mostQuestsCompleted.length > 0 && ( - - )} -
- )} + } + entries={highestReputation} + unit="reputation" + /> + + } + entries={mostQuestsCompleted} + unit="quests" + /> +
); From 1543b0e145f7a0b1d7da10f2996a504e584dcc4c Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 16:29:07 +0300 Subject: [PATCH 50/67] feat(game-center): drop the stat icons, frost the level badge The four snapshot tiles lose their icons. Once they all shared one colour the glyphs stopped distinguishing anything, and the labels already say what each number is. The icon imports go with them. The level badge becomes glass instead of a solid white square: a translucent white gradient over the panel, a lit top edge, a soft drop shadow and a 6px backdrop blur, so the panel's pattern shows through it. The number turns white to sit on the darker surface. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 59 ++++--------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index f8eef41c66a..2ca7acb8fdd 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -6,23 +6,14 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { IconSize } from '../Icon'; import { ProgressBar } from '../fields/ProgressBar'; -import { - HotIcon, - MedalBadgeIcon, - ReputationLightningIcon, - StarIcon, -} from '../icons'; type HudStat = { - icon: ReactElement; label: string; value: string; }; const HudStatTile = ({ - icon, label, value, className, @@ -33,15 +24,9 @@ const HudStatTile = ({ className, )} > -
- {icon} - - {label} - -
+ + {label} + {value} @@ -61,6 +46,14 @@ const levelPanelStyle = { backgroundSize: 'auto, auto, auto, 16px 16px', }; +// 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 { level: number; levelProgress: number; @@ -89,49 +82,22 @@ export const LevelHud = ({ const stats: HudStat[] = [ { - icon: ( - - ), label: 'Streak', value: streakValue, }, { - icon: ( - - ), label: 'Longest', value: longestValue, }, ...(achievements ? [ { - icon: ( - - ), label: 'Badges', value: `${achievements.unlocked}/${achievements.total}`, }, ] : []), { - icon: ( - - ), label: 'Total XP', value: isPending ? '...' : totalXp.toLocaleString(), }, @@ -146,7 +112,8 @@ export const LevelHud = ({ {/* The number stands alone, so the chip carries the meaning for screen readers. */}
Date: Mon, 24 Aug 2026 16:38:14 +0300 Subject: [PATCH 51/67] feat(game-center): quieter milestones, glass topic rows, real award art Milestones: the CLAIMED stamp comes off, the reward chips lose their glyphs, and the progress bar thins to 1px and turns neutral. The bar uses bg-text-primary rather than a literal white so it stays visible in the light theme; in dark it reads as the white that was asked for. Claimed still reads from its green bar and status label. Topic rows take the level badge's frosted glass. The awards grid mock carries the real catalogue art, eight distinct pieces, instead of every award falling back to the default dog. The pane headers put the label above the number, and the Claimable gifts strip is hidden on the page and in the story. ClaimableGifts itself stays, so restoring it is one line. Also: the level number centres in its badge, and the achievement shelf stays a rail after trying it as a grid. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 2 +- .../pages/GameCenterRedesign.stories.tsx | 61 ++-- .../pages/ProgressHeroDesigns.stories.tsx | 338 ++++++++++++++++++ .../game-center/BadgeTrophyCase.tsx | 24 +- .../game-center/MilestoneQuestList.tsx | 24 +- .../game-center/SeeAllAchievementsCard.tsx | 2 +- packages/webapp/pages/game-center/index.tsx | 3 - 7 files changed, 390 insertions(+), 64 deletions(-) create mode 100644 packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index 2ca7acb8fdd..fe0cb45869a 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -121,7 +121,7 @@ export const LevelHud = ({ bold // Centering the em box leaves digits sitting low: the box // reserves descender space the glyphs never use. - className="-translate-y-[0.75px] !font-black tabular-nums !leading-none" + className="w-full -translate-y-[0.75px] text-center !font-black tabular-nums !leading-none" > {level} diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 2c02885ecaa..ff4fd124c44 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -26,7 +26,6 @@ import { } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { MilestoneQuestList } from '../../../webapp/components/game-center/MilestoneQuestList'; -import { ClaimableGiftsSection } from '../../../webapp/components/game-center/ClaimableGifts'; import { CommunityPulse } from '../../../webapp/components/game-center/CommunityPulse'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import { @@ -259,40 +258,38 @@ const badges = [ }, ]; -const offers = [ +// 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 }, { - impressionUid: 'offer-disney', - clickUrl: '#', - title: 'Disney+ for $4.99/mo for 3 months', - advertiserName: 'Disney+', - perk: 'Unlimited entertainment', - badgeLabel: 'discount' as const, + 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 }, { - impressionUid: 'offer-hulu', - clickUrl: '#', - title: '30 days free trial on Hulu', - advertiserName: 'Hulu', - perk: 'Movies, shows & live TV', - badgeLabel: 'free_trial' as const, + id: 'hotdog', + name: 'Hotdog', + image: awardArt('Hotdog'), + count: 21, + value: 125, }, + { id: 'star', name: 'Star', image: awardArt('Star'), count: 33, value: 100 }, { - impressionUid: 'offer-notion', - clickUrl: '#', - title: '3 months of Notion Business, free', - advertiserName: 'Notion', - perk: 'Notes, Tasks, AI', - badgeLabel: 'free_trial' as const, + id: 'coffee', + name: 'Coffee', + image: awardArt('Coffee'), + count: 41, + value: 75, }, -]; - -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 leaders = [ @@ -415,12 +412,6 @@ const GameCenterRedesign = () => ( />
- undefined} - /> - diff --git a/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx b/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx new file mode 100644 index 00000000000..973c7d0c298 --- /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'; + +/* ── 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' }, +]; + +// Same treatment the shipped panel uses: off-token purples, so they live in a +// style object rather than arbitrary classes the no-custom-color rule rejects. +const panelStyle = { + backgroundColor: '#2A0B3D', + backgroundImage: [ + 'radial-gradient(circle at 14% 22%, rgba(230,105,251,0.32), transparent 58%)', + 'radial-gradient(circle at 94% 86%, rgba(122,63,255,0.30), transparent 62%)', + 'repeating-linear-gradient(115deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 14px)', + 'radial-gradient(rgba(255,255,255,0.10) 1px, transparent 1px)', + ].join(', '), + backgroundSize: 'auto, auto, auto, 16px 16px', +}; + +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/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index 8f7d23860bb..e8d3277222d 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -16,6 +16,17 @@ import { TimeFormatType, } from '@dailydotdev/shared/src/lib/dateFormat'; +// Frosted glass, matching the level badge: a translucent wash and a lit +// top edge rather than a flat fill. +const badgeRowStyle = { + background: + 'linear-gradient(145deg, rgba(255,255,255,0.55), rgba(255,255,255,0.16))', + border: '1px solid rgba(255,255,255,0.35)', + boxShadow: + 'inset 0 1px 0 rgba(255,255,255,0.6), 0 4px 14px -6px rgba(0,0,0,0.35)', + padding: '0.75rem', +}; + export const BadgeRow = ({ issuedAt, keyword, @@ -24,7 +35,10 @@ export const BadgeRow = ({ const title = keyword.flags?.title || keyword.value; return ( -
+
{image ? ( (
- - {value} - {label} + + {value} +
{children}
diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index 653cdb8cfb8..f3e0d6ce668 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -34,8 +34,6 @@ import { } from '@dailydotdev/shared/src/graphql/quests'; import { sortMilestoneQuests } from '../../lib/gameCenter'; -// Only the glyph carries the reward's colour — the amount inherits the text -// token so the chip stays legible on the light surface too. const RewardChipIcon = ({ type }: { type: QuestRewardType }): ReactElement => { if (type === QuestRewardType.Cores) { return ( @@ -89,7 +87,7 @@ const MilestoneQuestCard = ({ return (
@@ -178,17 +177,6 @@ const MilestoneQuestCard = ({ )}
- - {isClaimed && ( - - - Claimed - - - )}
); }; diff --git a/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx b/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx index 896a20d4da8..50c043dffe1 100644 --- a/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx +++ b/packages/webapp/components/game-center/SeeAllAchievementsCard.tsx @@ -18,7 +18,7 @@ export const SeeAllAchievementsCard = ({ }: SeeAllAchievementsCardProps): ReactElement => { return ( - + diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 7d8e55273d3..3a107b355d1 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -81,7 +81,6 @@ import { BadgeRow, BadgeTrophyCase, } from '../../components/game-center/BadgeTrophyCase'; -import { ClaimableGifts } from '../../components/game-center/ClaimableGifts'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; import { SeeAllAchievementsCard } from '../../components/game-center/SeeAllAchievementsCard'; import { CommunityPulse } from '../../components/game-center/CommunityPulse'; @@ -738,8 +737,6 @@ function GameCenterPage({ {milestoneQuestContent} - - {showAchievements && ( <>
From 842b0184cb3deafe5a0e1a4b0f2f59d7193aec01 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 24 Aug 2026 17:47:12 +0300 Subject: [PATCH 52/67] Revert "frosted glass on the topic rows" The rows go back to a flat background-default fill. Co-Authored-By: Claude Opus 5 --- .../components/game-center/BadgeTrophyCase.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index e8d3277222d..7706ab64026 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -16,17 +16,6 @@ import { TimeFormatType, } from '@dailydotdev/shared/src/lib/dateFormat'; -// Frosted glass, matching the level badge: a translucent wash and a lit -// top edge rather than a flat fill. -const badgeRowStyle = { - background: - 'linear-gradient(145deg, rgba(255,255,255,0.55), rgba(255,255,255,0.16))', - border: '1px solid rgba(255,255,255,0.35)', - boxShadow: - 'inset 0 1px 0 rgba(255,255,255,0.6), 0 4px 14px -6px rgba(0,0,0,0.35)', - padding: '0.75rem', -}; - export const BadgeRow = ({ issuedAt, keyword, @@ -35,10 +24,7 @@ export const BadgeRow = ({ const title = keyword.flags?.title || keyword.value; return ( -
+
{image ? ( Date: Mon, 24 Aug 2026 17:52:23 +0300 Subject: [PATCH 53/67] feat(game-center): use the daily.dev hero art behind the level The panel's pattern was four hand-rolled gradients standing in for artwork. It now carries the marketing site's main image, added to lib/image as gameCenterLevelBackground. A purple wash sits over it, heaviest on the left (94%) and lightest on the right (42%), so the level badge, bar and XP readout keep their contrast while the art stays visible past them. The crop follows the site's own desktop framing at 46% 50%. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/quest/LevelHud.tsx | 12 +++++++----- packages/shared/src/lib/image.ts | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index fe0cb45869a..619ac3e8ffc 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -7,6 +7,7 @@ import { TypographyType, } from '../typography/Typography'; import { ProgressBar } from '../fields/ProgressBar'; +import { gameCenterLevelBackground } from '../../lib/image'; type HudStat = { label: string; @@ -38,12 +39,13 @@ const HudStatTile = ({ const levelPanelStyle = { backgroundColor: '#2A0B3D', backgroundImage: [ - 'radial-gradient(circle at 14% 22%, rgba(230,105,251,0.32), transparent 58%)', - 'radial-gradient(circle at 94% 86%, rgba(122,63,255,0.30), transparent 62%)', - 'repeating-linear-gradient(115deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 14px)', - 'radial-gradient(rgba(255,255,255,0.10) 1px, transparent 1px)', + // 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, auto, auto, 16px 16px', + backgroundSize: 'auto, cover', + backgroundPosition: 'center, 46% 50%', }; // Frosted glass over the patterned panel: a translucent white wash, a 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'; From 874cbafa6b2ded50ade16d16312cfa6842b695bf Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 09:51:46 +0300 Subject: [PATCH 54/67] feat(storybook): three full-bleed progress hero designs Options for turning the boxed snapshot into a hero that spans the width with no top margin: a wide banner, a stacked hero, and a HUD bar. They share the shipped panel's artwork and glass badge so the comparison is about layout, not treatment. Nothing on the page changes. Co-Authored-By: Claude Opus 5 --- .../stories/pages/ProgressHeroDesigns.stories.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx b/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx index 973c7d0c298..0c08a02ea0c 100644 --- a/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx +++ b/packages/storybook/stories/pages/ProgressHeroDesigns.stories.tsx @@ -8,6 +8,7 @@ import { 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 ────────────────────────────────────────────────────── */ @@ -23,17 +24,16 @@ const STATS = [ { label: 'Total XP', value: '3,420' }, ]; -// Same treatment the shipped panel uses: off-token purples, so they live in a -// style object rather than arbitrary classes the no-custom-color rule rejects. +// 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: [ - 'radial-gradient(circle at 14% 22%, rgba(230,105,251,0.32), transparent 58%)', - 'radial-gradient(circle at 94% 86%, rgba(122,63,255,0.30), transparent 62%)', - 'repeating-linear-gradient(115deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 14px)', - 'radial-gradient(rgba(255,255,255,0.10) 1px, transparent 1px)', + '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, auto, auto, 16px 16px', + backgroundSize: 'auto, cover', + backgroundPosition: 'center, 46% 50%', }; const glassStyle = { From 12f3099690700f22550c13678f53bee9d1c81694 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 10:07:40 +0300 Subject: [PATCH 55/67] feat(game-center): make the snapshot a full-bleed HUD bar Hero 3. The snapshot was a bordered card under an eyebrow and a greeting heading, roughly 260px before the quests began. It becomes an 88px band spanning the full width, flush to the top: level badge, name, progress bar with its readout, and the four stats inline. The eyebrow and greeting go. The bar carries the name and level itself, so repeating them above was redundant, and the heading was most of what made the section tall. The bleed cancels the page container's px-4/tablet:px-8 and py-6 with matching negative margins, since the hero has to escape padding the rest of the page still wants. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 109 +++++++++--------- .../stories/components/LevelHud.stories.tsx | 1 + .../pages/GameCenterRedesign.stories.tsx | 15 +-- packages/webapp/pages/game-center/index.tsx | 20 +--- 4 files changed, 60 insertions(+), 85 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index 619ac3e8ffc..75e22c14fee 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -1,34 +1,20 @@ import type { ReactElement } from 'react'; import React from 'react'; -import classNames from 'classnames'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../typography/Typography'; +import { Typography, TypographyType } from '../typography/Typography'; import { ProgressBar } from '../fields/ProgressBar'; import { gameCenterLevelBackground } from '../../lib/image'; -type HudStat = { +type HudStatProps = { label: string; value: string; }; -const HudStatTile = ({ - label, - value, - className, -}: HudStat & { className?: string }): ReactElement => ( -
- +const HudStat = ({ label, value }: HudStatProps): ReactElement => ( +
+ {label} - + {value}
@@ -57,6 +43,7 @@ const levelBadgeStyle = { }; export interface LevelHudProps { + name: string; level: number; levelProgress: number; totalXp: number; @@ -69,6 +56,7 @@ export interface LevelHudProps { } export const LevelHud = ({ + name, level, levelProgress, totalXp, @@ -82,7 +70,7 @@ export const LevelHud = ({ const streakValue = isPending ? '...' : `${currentStreak.toLocaleString()}d`; const longestValue = isPending ? '...' : `${longestStreak.toLocaleString()}d`; - const stats: HudStat[] = [ + const stats: HudStatProps[] = [ { label: 'Streak', value: streakValue, @@ -106,28 +94,41 @@ export const LevelHud = ({ ]; return ( -
-
- {/* The number stands alone, so the chip carries the meaning for - screen readers. */} +
+
{level}
+
+ + {name} + + + Level {level} + +
+
+ +
- - {xpInLevel.toLocaleString()} /{' '} - {(xpInLevel + xpToNextLevel).toLocaleString()} - +
+ + {xpInLevel.toLocaleString()} /{' '} + {(xpInLevel + xpToNextLevel).toLocaleString()} + + + {xpToNextLevel.toLocaleString()} XP to level {level + 1} + +
-
- {stats.map((stat, index) => ( - + +
+ {stats.map((stat) => ( + ))}
diff --git a/packages/storybook/stories/components/LevelHud.stories.tsx b/packages/storybook/stories/components/LevelHud.stories.tsx index 52988dc39ba..d2c51a5afc6 100644 --- a/packages/storybook/stories/components/LevelHud.stories.tsx +++ b/packages/storybook/stories/components/LevelHud.stories.tsx @@ -6,6 +6,7 @@ const meta: Meta = { title: 'Components/Quest/LevelHud', component: LevelHud, args: { + name: 'Tomer', level: 14, levelProgress: 70, totalXp: 3420, diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index ff4fd124c44..2022adfdd42 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -376,19 +376,9 @@ const communityStats = { const GameCenterRedesign = () => (
-
-
- - Progress snapshot - - - Tomer, here's how you're doing. - +
( achievements={{ unlocked: 9, total: 24 }} isPending={false} /> -
diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 3a107b355d1..4de30a93cd9 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -565,26 +565,10 @@ function GameCenterPage({ )} -
-
- - Progress snapshot - - - {firstName}, here's how you're doing. - -
- +
{questDashboard ? ( Date: Tue, 25 Aug 2026 10:19:32 +0300 Subject: [PATCH 56/67] fix(game-center): keep the HUD stacked at every width The bar collapsed into a single row from laptop up, which is not the design that was picked: the level, the progress bar and the stats each want their own row, and squeezing them onto one line left the artwork with nothing but a sliver behind the stats. It stays three rows at every width, 180px, and the bar spans the full width as intended. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/quest/LevelHud.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index 75e22c14fee..311ad864125 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -95,7 +95,7 @@ export const LevelHud = ({ return (
From 7db411f1a1161de43fa170553c4f3f5b15177ae6 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 10:41:26 +0300 Subject: [PATCH 57/67] feat(game-center): lead the HUD with the greeting The name sat at callout size over a "Level 14" caption, which repeated the number already filling the badge beside it. The greeting takes its place as an H1 at title1, and the caption goes. The "600 XP to level 15" readout goes too, leaving the bar with just its fraction underneath. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index 311ad864125..bdf95d10bc1 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -1,6 +1,10 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { Typography, TypographyType } from '../typography/Typography'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../typography/Typography'; import { ProgressBar } from '../fields/ProgressBar'; import { gameCenterLevelBackground } from '../../lib/image'; @@ -111,21 +115,14 @@ export const LevelHud = ({ {level}
-
- - {name} - - - Level {level} - -
+ + {name}, here's how you're doing. +
@@ -147,12 +144,6 @@ export const LevelHud = ({ {xpInLevel.toLocaleString()} /{' '} {(xpInLevel + xpToNextLevel).toLocaleString()} - - {xpToNextLevel.toLocaleString()} XP to level {level + 1} -
From 1dfe6f9b38ca8718a1b675f327d34a45abbdefcb Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 10:43:38 +0300 Subject: [PATCH 58/67] feat(game-center): give the HUD room to breathe The strip ran 20px of vertical padding against 32px horizontal, so the content sat tight to the top and bottom edges. It goes to 32px on mobile and 40px from tablet up, matching the horizontal rhythm. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/quest/LevelHud.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index bdf95d10bc1..4c1f49e81e5 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -99,7 +99,7 @@ export const LevelHud = ({ return (
From ea28b6c79e96bcbafc96bf5e1e91b3c8f6d00d95 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 10:50:47 +0300 Subject: [PATCH 59/67] feat(game-center): drop every scroller, split badges from achievements The achievement shelf and gifts strip were horizontal scrollers and the badge column was capped at max-h-80. All three hid content behind a scrollbar. They become wrapping grids and an uncapped column, so nothing sits past an edge: the page now has no scrolling element at all and no horizontal document overflow. Cards drop their fixed widths to fill their columns. The HUD's "Badges" tile was showing achievement counts. Badges now counts top reader badges and a separate Achievements tile carries the unlocked/total pair that tile used to show. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/quest/LevelHud.tsx | 7 ++++++- .../components/achievements/AchievementShelfCard.tsx | 2 +- packages/storybook/stories/components/LevelHud.stories.tsx | 1 + .../storybook/stories/pages/GameCenterRedesign.stories.tsx | 5 +++-- packages/webapp/components/game-center/ClaimableGifts.tsx | 4 ++-- .../components/game-center/SeeAllAchievementsCard.tsx | 2 +- packages/webapp/pages/game-center/index.tsx | 5 +++-- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index 4c1f49e81e5..de03edee775 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -55,6 +55,7 @@ export interface LevelHudProps { xpToNextLevel: number; currentStreak: number; longestStreak: number; + badges?: number; achievements?: { unlocked: number; total: number }; isPending: boolean; } @@ -68,6 +69,7 @@ export const LevelHud = ({ xpToNextLevel, currentStreak, longestStreak, + badges, achievements, isPending, }: LevelHudProps): ReactElement => { @@ -83,10 +85,13 @@ export const LevelHud = ({ label: 'Longest', value: longestValue, }, + ...(badges === undefined + ? [] + : [{ label: 'Badges', value: badges.toLocaleString() }]), ...(achievements ? [ { - label: 'Badges', + label: 'Achievements', value: `${achievements.unlocked}/${achievements.total}`, }, ] diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index 75765a19f06..96f9bf37cbc 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -93,7 +93,7 @@ export function AchievementShelfCard({ return ( <> -
+
{/* `absolute` has to come from the prop: LazyImage appends its own `relative` after our classes, and that wins in the compiled CSS. */} = { xpToNextLevel: 600, currentStreak: 12, longestStreak: 28, + badges: 4, achievements: { unlocked: 9, total: 24 }, isPending: false, }, diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 2022adfdd42..7f62e85b9bb 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -386,6 +386,7 @@ const GameCenterRedesign = () => ( xpToNextLevel={600} currentStreak={12} longestStreak={28} + badges={4} achievements={{ unlocked: 9, total: 24 }} isPending={false} /> @@ -406,7 +407,7 @@ const GameCenterRedesign = () => (
-
+
{achievements.map((item) => ( ( +
{badges.map((badge) => ( +
-
+
{offers.map((offer) => ( { return ( - + diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 4de30a93cd9..0745a66dd32 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -383,7 +383,7 @@ function GameCenterPage({ ); } else if (featuredAchievements.length > 0) { achievementShelfContent = ( -
+
{featuredAchievements.map((achievement) => ( 0) { badgeCaseContent = ( -
+
{topReaderBadges.map((badge) => ( Date: Tue, 25 Aug 2026 10:57:21 +0300 Subject: [PATCH 60/67] feat(game-center): portrait achievement cards, medal on the badge chip Four columns made the cards 271px wide against a 272px height, so they read as squares rather than the posters they were. Five columns bring them back to 214x272, close to the original 208x272, and all five items fit on one row. The gold chip gains a medal icon beside its label. Co-Authored-By: Claude Opus 5 --- .../storybook/stories/pages/GameCenterRedesign.stories.tsx | 2 +- packages/webapp/components/game-center/BadgeTrophyCase.tsx | 5 ++++- packages/webapp/pages/game-center/index.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 7f62e85b9bb..d1867df0ab1 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -407,7 +407,7 @@ const GameCenterRedesign = () => (
-
+
{achievements.map((item) => ( + Top reader diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 0745a66dd32..76067df5e38 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -383,7 +383,7 @@ function GameCenterPage({ ); } else if (featuredAchievements.length > 0) { achievementShelfContent = ( -
+
{featuredAchievements.map((achievement) => ( Date: Tue, 25 Aug 2026 11:23:31 +0300 Subject: [PATCH 61/67] feat(game-center): badge paging, award value, and assorted polish Badges page four at a time with prev/next and an "n-m of total" counter, since a reader with ten topics would otherwise run the column past the awards beside it. The awards pane gains "Total earned", the collection's worth in Cores with the core glyph, from a new totalAwardValue on the award summary. Community pulse: the leaderboard icons come out, rows go from 30px to 46px apart, and the all-time total moves under the columns in the same label-then-value shape the case panes use. Milestones: in-progress bars turn cabbage and claimed ones text-tertiary so a spent milestone reads as spent; the claim button turns cabbage with white 900 text; and the locked fade goes, since a locked milestone still shows real progress. New .pointer-default utility keeps the I-beam off copy that is chrome rather than content, while links, buttons and fields keep their own cursors. The earlier cursor-default class did nothing in Storybook, which renders its own wrapper. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/quest/LevelHud.tsx | 2 +- packages/shared/src/styles/base.css | 76 ++++++++++ .../pages/GameCenterRedesign.stories.tsx | 62 ++++++-- .../game-center/BadgeTrophyCase.tsx | 132 ++++++++++++++---- .../components/game-center/CommunityPulse.tsx | 65 +++------ .../game-center/MilestoneQuestList.tsx | 9 +- packages/webapp/lib/gameCenter.ts | 13 +- packages/webapp/pages/game-center/index.tsx | 45 +++--- 8 files changed, 294 insertions(+), 110 deletions(-) diff --git a/packages/shared/src/components/quest/LevelHud.tsx b/packages/shared/src/components/quest/LevelHud.tsx index de03edee775..e4ea48cd5aa 100644 --- a/packages/shared/src/components/quest/LevelHud.tsx +++ b/packages/shared/src/components/quest/LevelHud.tsx @@ -109,7 +109,7 @@ export const LevelHud = ({ >
diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index 68e4f8c60bf..de4e0bbc9ca 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1119,6 +1119,82 @@ meter::-webkit-meter-bar { } } + /* 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/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index d1867df0ab1..63b2bc9846a 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -29,7 +29,7 @@ import { MilestoneQuestList } from '../../../webapp/components/game-center/Miles import { CommunityPulse } from '../../../webapp/components/game-center/CommunityPulse'; import { TrophyGrid } from '../../../webapp/components/game-center/TrophyGrid'; import { - BadgeRow, + BadgePager, BadgeTrophyCase, } from '../../../webapp/components/game-center/BadgeTrophyCase'; import type { AwardWithRarity } from '../../../webapp/lib/gameCenter'; @@ -256,6 +256,26 @@ const badges = [ 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 @@ -375,7 +395,7 @@ const communityStats = { const GameCenterRedesign = () => ( -
+
( - {badges.map((badge) => ( - - ))} -
+ ({ + ...badge, + id: badge.keyword.value, + total: badges.length - index, + user: { name: 'Tomer', username: 'tomer', image: '' }, + }))} + /> } - badgeCount={badges.length.toString()} + badgeStats={[ + { label: 'Topics mastered', value: badges.length.toString() }, + ]} awards={} - awardCount="87" + awardStats={[ + { label: 'Total awards', value: '87' }, + { + label: 'Total earned', + icon: ( + + ), + value: awards + .reduce((total, award) => total + award.value * award.count, 0) + .toLocaleString(), + }, + ]} />
diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index 29f9871f445..2443d1292b6 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -1,5 +1,14 @@ import type { ReactElement, ReactNode } from 'react'; -import React 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, @@ -16,7 +25,6 @@ import { TimeFormatType, } from '@dailydotdev/shared/src/lib/dateFormat'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { MedalBadgeIcon } from '@dailydotdev/shared/src/components/icons'; export const BadgeRow = ({ issuedAt, @@ -71,53 +79,121 @@ export const BadgeRow = ({ ); }; +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) => ( + + ))} + + {pageCount > 1 && ( +
+ + {start + 1}-{start + visible.length} of {badges.length} + +
+
+
+ )} +
+ ); +}; + +type PaneStat = { + label: string; + value: string; + icon?: ReactNode; +}; + const Pane = ({ - value, - label, + stats, children, }: { - value: string; - label: string; + stats: PaneStat[]; children: ReactNode; }): ReactElement => (
-
- - {label} - - - {value} - -
{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; - badgeCount: string; + badgeStats: PaneStat[]; awards: ReactNode; - awardCount: string; + awardStats: PaneStat[]; }; export const BadgeTrophyCase = ({ badges, - badgeCount, + badgeStats, awards, - awardCount, + awardStats, }: BadgeTrophyCaseProps): ReactElement => { return (
- - {badges} - - - {awards} - + {badges} + {awards}
); }; diff --git a/packages/webapp/components/game-center/CommunityPulse.tsx b/packages/webapp/components/game-center/CommunityPulse.tsx index 6ad6796b267..6e085f258c2 100644 --- a/packages/webapp/components/game-center/CommunityPulse.tsx +++ b/packages/webapp/components/game-center/CommunityPulse.tsx @@ -12,37 +12,28 @@ import { TypographyColor, 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 { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; const raceLength = 5; type RaceProps = { title: string; - icon: ReactElement; entries: UserLeaderboard[]; unit: string; }; -const Race = ({ title, icon, entries, unit }: RaceProps): ReactElement => { +const Race = ({ title, entries, unit }: 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 ( -
-
- {icon} - - {title} - -
-
+
+ + {title} + +
{ranked.map((entry, index) => (
{ /> -
+
{entry.user.name} @@ -102,43 +93,27 @@ export const CommunityPulse = ({ mostQuestsCompleted, }: CommunityPulseProps): ReactElement => (
+
+ + +
+ {stats && ( -
- - {formatDataTileValue(stats.totalCount)} - +
quests completed all-time + + {formatDataTileValue(stats.totalCount)} +
)} -
- - } - entries={highestReputation} - unit="reputation" - /> - - } - entries={mostQuestsCompleted} - unit="quests" - /> -
); diff --git a/packages/webapp/components/game-center/MilestoneQuestList.tsx b/packages/webapp/components/game-center/MilestoneQuestList.tsx index f3e0d6ce668..eba010e95b3 100644 --- a/packages/webapp/components/game-center/MilestoneQuestList.tsx +++ b/packages/webapp/components/game-center/MilestoneQuestList.tsx @@ -91,7 +91,6 @@ const MilestoneQuestCard = ({ // Only a claimable milestone gets a filled surface, so the ones you // can act on read forward of the ones you cannot. canClaim && 'bg-background-subtle', - quest.locked && 'opacity-64', )} > @@ -149,8 +148,8 @@ const MilestoneQuestCard = ({ // text-primary rather than a literal white, so the bar stays // visible when the theme is light. barColor: classNames( - isClaimed && 'bg-accent-avocado-default', - !isClaimed && 'bg-text-primary', + isClaimed && 'bg-text-tertiary', + !isClaimed && 'bg-accent-cabbage-default', ), }} /> diff --git a/packages/webapp/lib/gameCenter.ts b/packages/webapp/lib/gameCenter.ts index bd8a64e9879..6e0727e7adb 100644 --- a/packages/webapp/lib/gameCenter.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -277,12 +277,17 @@ 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), + ); return { unlockedCount: unlocked.length, @@ -324,6 +329,8 @@ export type GameCenterAwardSummary = { // Awards ordered rarest-first for the trophy grid. awardsByRarity: AwardWithRarity[]; totalAwards: number; + // What the collection is worth in Cores, counting duplicates. + totalAwardValue: number; uniqueAwards: number; favoriteAward: UserProductSummary | null; }; @@ -372,6 +379,10 @@ export const getAwardSummary = ( awards: allAwards, awardsByRarity, 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 76067df5e38..2b5a6ea1944 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -71,6 +71,7 @@ import type { UserLeaderboard } from '@dailydotdev/shared/src/components/cards/L import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ArrowIcon, + CoreIcon, MedalBadgeIcon, PinIcon, } from '@dailydotdev/shared/src/components/icons'; @@ -78,7 +79,7 @@ import { getLayout as getFooterNavBarLayout } from '../../components/layouts/Foo import { getLayout } from '../../components/layouts/MainLayout'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { - BadgeRow, + BadgePager, BadgeTrophyCase, } from '../../components/game-center/BadgeTrophyCase'; import { MilestoneQuestList } from '../../components/game-center/MilestoneQuestList'; @@ -421,6 +422,12 @@ 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); @@ -435,18 +442,7 @@ function GameCenterPage({ /> ); } else if (topReaderBadges.length > 0) { - badgeCaseContent = ( -
- {topReaderBadges.map((badge) => ( - - ))} -
- ); + badgeCaseContent = ; } else { badgeCaseContent = ( )} - +
{questDashboard ? ( + ), + }, + ]} />
From 0480a7758df767fb0fbdf6ffb3f1b88a0e36a1f1 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 11:35:45 +0300 Subject: [PATCH 62/67] feat(game-center): one progress bar everywhere, pager pinned low The community races and the achievement cards each drew their own bar: different heights (5px, 6px), different tracks (a translucent white on the card art, background-default in the races) and hand-rolled divs. All three now use the shared ProgressBar with the milestone's settings, so they measure identically: 4px, 9999px radius, same track and fill. The badge pager's controls take mt-auto inside a flex-1 column, so they sit at the foot of the pane rather than riding up under a short last page. Co-Authored-By: Claude Opus 5 --- .../achievements/AchievementShelfCard.tsx | 31 ++++++++++++------- .../game-center/BadgeTrophyCase.tsx | 4 +-- .../components/game-center/CommunityPulse.tsx | 18 ++++++----- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index 96f9bf37cbc..bb1f2359e9e 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -14,6 +14,7 @@ import { 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 { @@ -176,12 +177,15 @@ export function AchievementShelfCard({ {progressLabel} -
-
-
+ )}
@@ -254,12 +258,15 @@ export function AchievementShelfCard({ > {progressLabel}
-
-
-
+ )}
diff --git a/packages/webapp/components/game-center/BadgeTrophyCase.tsx b/packages/webapp/components/game-center/BadgeTrophyCase.tsx index 2443d1292b6..f41740315a2 100644 --- a/packages/webapp/components/game-center/BadgeTrophyCase.tsx +++ b/packages/webapp/components/game-center/BadgeTrophyCase.tsx @@ -92,7 +92,7 @@ export const BadgePager = ({ badges }: BadgePagerProps): ReactElement => { const visible = badges.slice(start, start + badgePageSize); return ( -
+
{visible.map((badge) => ( { ))} {pageCount > 1 && ( -
+
{ {formatDataTileValue(entry.score)}
-
-
-
+
))} From 8b91dfa9544a46b4dc02f51ed99c1c7134507f50 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 12:16:53 +0300 Subject: [PATCH 63/67] feat(game-center): drop the rarity rings, order awards by count The shelf cards carried a coloured ring with a matching glow for gold and emerald tiers. Both come off, along with the class map that fed them; the rarity pill still names the tier. The trophy grid orders by how many you hold rather than by rarity, so the award you have most of leads: a new awardsByCount on the summary, leaving awardsByRarity in place for anything that wants it. Co-Authored-By: Claude Opus 5 --- .../achievements/AchievementShelfCard.tsx | 14 -------------- .../stories/pages/GameCenterRedesign.stories.tsx | 4 +++- packages/webapp/lib/gameCenter.ts | 7 ++++++- packages/webapp/pages/game-center/index.tsx | 2 +- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index bb1f2359e9e..b8e93f6747a 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -56,11 +56,6 @@ const formatUnlockedAt = (value: string): string => { const isEmerald = (tier: AchievementRarityTier | null) => tier === AchievementRarityTier.Emerald; -const slabRingClasses: Record<'gold' | 'emerald', string> = { - gold: 'border-[#efab27] shadow-[0_0_16px_-2px_#efab27]', - emerald: 'border-[#1dbf8c] shadow-[0_0_16px_-2px_#1dbf8c]', -}; - const slabPillClasses: Record<'gold' | 'emerald', string> = { gold: 'bg-[#efab27]', emerald: 'bg-[#1dbf8c]', @@ -110,15 +105,6 @@ export function AchievementShelfCard({
- {slabTier && ( -
- )} - {/* Covers the slab so the whole card opens the detail modal, without nesting the track control inside another button. */}
- + ); } else { From 6f7429812ad85aeffbb61d88b4d5766e34a79572 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 12:20:49 +0300 Subject: [PATCH 64/67] feat(game-center): full achievement shelf, badges leaderboard The shelf showed four curated achievements. It now shows everything unlocked or under way, ordered by progress, via a new shelfAchievements on the summary. Untouched achievements stay out, since an empty bar says nothing about your history. The grid already wraps, so the list grows downward instead of off the right edge. Community pulse swaps its reputation column for mostAchievementPoints under a "Most badges" heading. There is no leaderboard for top reader badge counts, so this is the closest the API offers. Co-Authored-By: Claude Opus 5 --- .../webapp/__tests__/GameCenterStaticProps.spec.ts | 10 +++++----- .../components/game-center/CommunityPulse.tsx | 4 ++-- packages/webapp/lib/gameCenter.ts | 14 ++++++++++++++ packages/webapp/pages/game-center/index.tsx | 14 +++++++------- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/webapp/__tests__/GameCenterStaticProps.spec.ts b/packages/webapp/__tests__/GameCenterStaticProps.spec.ts index aa52c44fa76..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'; @@ -180,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) { @@ -208,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) { diff --git a/packages/webapp/components/game-center/CommunityPulse.tsx b/packages/webapp/components/game-center/CommunityPulse.tsx index 9c5bb385020..01bf9ba85df 100644 --- a/packages/webapp/components/game-center/CommunityPulse.tsx +++ b/packages/webapp/components/game-center/CommunityPulse.tsx @@ -97,9 +97,9 @@ export const CommunityPulse = ({
diff --git a/packages/webapp/lib/gameCenter.ts b/packages/webapp/lib/gameCenter.ts index f68d946b905..e31ed47ab9e 100644 --- a/packages/webapp/lib/gameCenter.ts +++ b/packages/webapp/lib/gameCenter.ts @@ -229,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 = ( @@ -289,6 +292,16 @@ export const getAchievementSummary = ( getAchievementProgressRatio(right) - getAchievementProgressRatio(left), ); + const shelfAchievements = [...allAchievements] + .filter( + (achievement) => + achievement.unlockedAt !== null || achievement.progress > 0, + ) + .sort( + (left, right) => + getAchievementProgressRatio(right) - getAchievementProgressRatio(left), + ); + return { unlockedCount: unlocked.length, totalCount: allAchievements.length, @@ -300,6 +313,7 @@ export const getAchievementSummary = ( rarestUnlocked, nextToUnlock, featuredAchievements, + shelfAchievements, }; }; diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index e71468865ca..a4364f56395 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -8,7 +8,7 @@ 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, + MOST_ACHIEVEMENT_POINTS_QUERY, MOST_QUESTS_COMPLETED_QUERY, QUEST_COMPLETION_STATS_QUERY, } from '@dailydotdev/shared/src/graphql/leaderboard'; @@ -284,7 +284,7 @@ function GameCenterPage({ ? 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), @@ -382,10 +382,10 @@ 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) => ( (HIGHEST_REPUTATION_QUERY, { limit: leaderboardLimit }), + mostAchievementPoints: UserLeaderboard[]; + }>(MOST_ACHIEVEMENT_POINTS_QUERY, { limit: leaderboardLimit }), gqlClient.request<{ mostQuestsCompleted: UserLeaderboard[]; }>(MOST_QUESTS_COMPLETED_QUERY, { limit: leaderboardLimit }), @@ -841,7 +841,7 @@ export async function getStaticProps(): Promise< return { props: { - highestReputation: highestReputationRes.highestReputation ?? [], + highestReputation: highestReputationRes.mostAchievementPoints ?? [], mostQuestsCompleted: mostQuestsCompletedRes.mostQuestsCompleted ?? [], questCompletionStats, }, From cd6b8477b001d81936ac5af166c44b75245aee0a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 12:27:31 +0300 Subject: [PATCH 65/67] feat(storybook): real catalogue and leaderboard data in the page mock Twelve achievements instead of four, five unlocked and seven under way, all pulled from the live catalogue so the shelf fills three rows with real artwork and rarity. The leaderboard rows come from the live queries too. The reputation figures were standing in for achievement points and read as tens of thousands; the real scores are 1,275 down to 1,135, which changes how the bars cluster. Co-Authored-By: Claude Opus 5 --- .../pages/GameCenterRedesign.stories.tsx | 203 +++++++++++++----- 1 file changed, 147 insertions(+), 56 deletions(-) diff --git a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx index 5dba944521a..1bedec28547 100644 --- a/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx +++ b/packages/storybook/stories/pages/GameCenterRedesign.stories.tsx @@ -193,22 +193,12 @@ const achievements: UserAchievement[] = [ 'Committed', 'Reach a 50-day reading streak', 'https://media.daily.dev/image/upload/v1770222887/achievements/Comitted.png', - null, + 'day streak', 50, 50, - 1.99, + 1.98, '2026-08-02T00:00:00.000Z', ), - 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.051, - null, - ), achievement( 'Boosted', 'Boost a post', @@ -216,9 +206,39 @@ const achievements: UserAchievement[] = [ null, 1, 1, - 0.061, + 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', @@ -226,7 +246,67 @@ const achievements: UserAchievement[] = [ 'upvotes received', 63, 100, - 0.541, + 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, ), ]; @@ -314,70 +394,81 @@ const awards: AwardWithRarity[] = [ const awardsByCount = [...awards].sort((left, right) => right.count - left.count); -const leaders = [ +// 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', - 76550, - 328, + 1220, ], [ - 'Joud Awad', - 'joudawad', - 'https://media.daily.dev/image/upload/s--dOB9RaXY--/f_auto/v1773320801/avatars/avatar_iaC4JsBU0lV8wBsc85fSh', - 74620, - 210, + 'Serdarcan Buyukdereli', + 'serdarbuyukdereli', + 'https://media.daily.dev/image/upload/s--tTV8hAPq--/f_auto/v1778701721/avatars/avatar_Su5HqluAE4wLRb1naHjtv', + 1180, ], [ - 'Randy', - 'randy', - 'https://media.daily.dev/image/upload/s--UjV4-KkB--/f_auto/v1708097210/avatars/avatar_HXYbbGcBO38Rfv7RrCBdA', - 69050, - 198, + 'Hadil Ben Abdallah', + 'hadilbenabdallah', + 'https://media.daily.dev/image/upload/s--qsFuKGv_--/t_logo,f_auto/public/noProfile', + 1135, ], +] as const; + +const questLeaders = [ [ - 'Ole-Martin', - 'ombratteng', - 'https://avatars.githubusercontent.com/u/1681525?v=4', - 65260, - 176, + 'Laszlo Szabo', + 'lezli01', + 'https://lh3.googleusercontent.com/a/ACg8ocKj3v7EFYJoXUUuro6ALF9fD3RTRATiBpBOVcOzqro4fy6bWqMm=s96-c', + 398, ], [ - 'Denis Bolkovskis', - 'denisb0', - 'https://media.daily.dev/image/upload/s--PGCuYx85--/f_auto,q_auto/v1/avatars/avatar_yRuVFf6IbfTylBjx9Dzvt', - 56520, - 155, + 'Jay', + 'finallyjay', + 'https://lh3.googleusercontent.com/a/ACg8ocL5i3hkxSSWLLoubyZSkPBN6T_N7QRlwpPOOyQWeyJV53Q=s96-c', + 352, ], [ - 'OrcDev', - 'orcdev', - 'https://avatars.githubusercontent.com/u/7549148?v=4', - 56390, - 149, + 'Bobby Iliev', + 'bobbyiliev', + 'https://avatars3.githubusercontent.com/u/21223421?v=4', + 335, ], [ - 'Anja P', - 'anjapcodes', - 'https://media.daily.dev/image/upload/s--M_c0s8Ky--/f_auto/v1721658650/avatars/avatar_WVJSfJtDe63PxQFAsmXFO', - 51350, - 141, + 'Keith Solomon', + 'ksolomon', + 'https://avatars.githubusercontent.com/u/251996?v=4', + 315, ], [ - 'Chris Bongers', - 'dailydevtips', - 'https://media.daily.dev/image/upload/s--9gxFz1e7--/f_auto/v1705902590/avatars/avatar_JUNiIGCV-', - 51285, - 138, + 'Keshav Ashiya', + 'keshavashiya', + 'https://avatars0.githubusercontent.com/u/20239068?v=4', + 308, ], ] as const; const board = (byQuests: boolean) => - leaders.map(([name, username, image, rep, quests], i) => ({ - score: byQuests ? quests : rep, - user: { id: `${byQuests ? 'q' : 'r'}${i}`, name, username, image }, - })) as never; + (byQuests ? questLeaders : pointsLeaders).map( + ([name, username, image, score], i) => ({ + score, + user: { id: `${byQuests ? 'q' : 'p'}${i}`, name, username, image }, + }), + ) as never; const communityStats = { totalCount: 90000, From 381589112f477bad66c413d798cc04a109892e2c Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 25 Aug 2026 12:40:11 +0300 Subject: [PATCH 66/67] feat(game-center): hover-only track, gold sub-1% chips, steady pager Track hides until hover or keyboard focus; Tracked stays visible, since it is a state worth seeing at rest rather than an affordance. Gold moves to where it means something. The rarity chip reserves it for the sub-1% band and every other tier takes a plain dark chip, while the gold wash moves from the awards pane to the top reader one, matching the chips it holds. The badge pager fills a short last page with invisible rows, so the column keeps its height and nothing below it shifts. Completed replaces Unlocked on the cards. The shelf breaks its progress ties by rarity, so the rarest completed achievement leads. Community pulse marks the viewer's own row as "You" in bold. Headline reads Trophies & Awards. Co-Authored-By: Claude Opus 5 --- .../achievements/AchievementShelfCard.tsx | 26 +++-- .../pages/GameCenterRedesign.stories.tsx | 21 ++++- .../game-center/BadgeTrophyCase.tsx | 36 ++++++- .../components/game-center/CommunityPulse.tsx | 94 +++++++++++-------- packages/webapp/lib/gameCenter.ts | 19 +++- packages/webapp/pages/game-center/index.tsx | 3 +- 6 files changed, 140 insertions(+), 59 deletions(-) diff --git a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx index b8e93f6747a..c43350e696a 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementShelfCard.tsx @@ -56,9 +56,11 @@ const formatUnlockedAt = (value: string): string => { const isEmerald = (tier: AchievementRarityTier | null) => tier === AchievementRarityTier.Emerald; -const slabPillClasses: Record<'gold' | 'emerald', string> = { - gold: 'bg-[#efab27]', - emerald: 'bg-[#1dbf8c]', +// 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({ @@ -79,7 +81,7 @@ export function AchievementShelfCard({ ? getAchievementRarityTier(achievement.rarity) : null; const slabTier = rarityTier - ? ((isEmerald(rarityTier) ? 'emerald' : 'gold') as 'gold' | 'emerald') + ? ((isEmerald(rarityTier) ? 'gold' : 'plain') as 'gold' | 'plain') : null; const rarityLabel = isEmerald(rarityTier) ? '<1%' @@ -117,7 +119,7 @@ export function AchievementShelfCard({ {slabTier && ( @@ -129,7 +131,13 @@ export function AchievementShelfCard({