From bc6b6987eadee7af3fed589d815a58d65974b26f Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 22:46:03 +0100 Subject: [PATCH 01/17] feat(avatars): add the avatar art and its manifest (TASK-22142) Twenty basic avatars plus three per badge for Bug Whisperer and Offramp User, drawn for avatar size in the badge house style. badge-assets.json now carries the avatar slugs from the API catalog, so the UI can build the picker and assert the files exist without keeping its own list of badge codes. --- public/avatars/badge/BUG_WHISPERER/beetle.svg | 1 + public/avatars/badge/BUG_WHISPERER/peek.svg | 1 + public/avatars/badge/BUG_WHISPERER/shell.svg | 1 + public/avatars/badge/OFFRAMP_USER/bolt.svg | 1 + public/avatars/badge/OFFRAMP_USER/spark.svg | 1 + public/avatars/badge/OFFRAMP_USER/wink.svg | 1 + public/avatars/basic/apple.svg | 1 + public/avatars/basic/avocado.svg | 1 + public/avatars/basic/cactus.svg | 1 + public/avatars/basic/cloud.svg | 1 + public/avatars/basic/cube.svg | 1 + public/avatars/basic/donut.svg | 1 + public/avatars/basic/drop.svg | 1 + public/avatars/basic/egg.svg | 1 + public/avatars/basic/fish.svg | 1 + public/avatars/basic/flower.svg | 1 + public/avatars/basic/frog.svg | 1 + public/avatars/basic/gem.svg | 1 + public/avatars/basic/ghost.svg | 1 + public/avatars/basic/heart.svg | 1 + public/avatars/basic/leaf.svg | 1 + public/avatars/basic/moon.svg | 1 + public/avatars/basic/mushroom.svg | 1 + public/avatars/basic/planet.svg | 1 + public/avatars/basic/star.svg | 1 + public/avatars/basic/sun.svg | 1 + src/components/Global/DotFaceAvatar.tsx | 206 ------------------ .../Global/__tests__/DotFaceAvatar.test.tsx | 42 ---- src/types/badge-assets.json | 28 +++ 29 files changed, 54 insertions(+), 248 deletions(-) create mode 100644 public/avatars/badge/BUG_WHISPERER/beetle.svg create mode 100644 public/avatars/badge/BUG_WHISPERER/peek.svg create mode 100644 public/avatars/badge/BUG_WHISPERER/shell.svg create mode 100644 public/avatars/badge/OFFRAMP_USER/bolt.svg create mode 100644 public/avatars/badge/OFFRAMP_USER/spark.svg create mode 100644 public/avatars/badge/OFFRAMP_USER/wink.svg create mode 100644 public/avatars/basic/apple.svg create mode 100644 public/avatars/basic/avocado.svg create mode 100644 public/avatars/basic/cactus.svg create mode 100644 public/avatars/basic/cloud.svg create mode 100644 public/avatars/basic/cube.svg create mode 100644 public/avatars/basic/donut.svg create mode 100644 public/avatars/basic/drop.svg create mode 100644 public/avatars/basic/egg.svg create mode 100644 public/avatars/basic/fish.svg create mode 100644 public/avatars/basic/flower.svg create mode 100644 public/avatars/basic/frog.svg create mode 100644 public/avatars/basic/gem.svg create mode 100644 public/avatars/basic/ghost.svg create mode 100644 public/avatars/basic/heart.svg create mode 100644 public/avatars/basic/leaf.svg create mode 100644 public/avatars/basic/moon.svg create mode 100644 public/avatars/basic/mushroom.svg create mode 100644 public/avatars/basic/planet.svg create mode 100644 public/avatars/basic/star.svg create mode 100644 public/avatars/basic/sun.svg delete mode 100644 src/components/Global/DotFaceAvatar.tsx delete mode 100644 src/components/Global/__tests__/DotFaceAvatar.test.tsx diff --git a/public/avatars/badge/BUG_WHISPERER/beetle.svg b/public/avatars/badge/BUG_WHISPERER/beetle.svg new file mode 100644 index 0000000000..8427a6a51d --- /dev/null +++ b/public/avatars/badge/BUG_WHISPERER/beetle.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/BUG_WHISPERER/peek.svg b/public/avatars/badge/BUG_WHISPERER/peek.svg new file mode 100644 index 0000000000..6afc101eb9 --- /dev/null +++ b/public/avatars/badge/BUG_WHISPERER/peek.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/BUG_WHISPERER/shell.svg b/public/avatars/badge/BUG_WHISPERER/shell.svg new file mode 100644 index 0000000000..cbfe894fa4 --- /dev/null +++ b/public/avatars/badge/BUG_WHISPERER/shell.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/OFFRAMP_USER/bolt.svg b/public/avatars/badge/OFFRAMP_USER/bolt.svg new file mode 100644 index 0000000000..4a3aae40aa --- /dev/null +++ b/public/avatars/badge/OFFRAMP_USER/bolt.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/OFFRAMP_USER/spark.svg b/public/avatars/badge/OFFRAMP_USER/spark.svg new file mode 100644 index 0000000000..aaf032c5bf --- /dev/null +++ b/public/avatars/badge/OFFRAMP_USER/spark.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/OFFRAMP_USER/wink.svg b/public/avatars/badge/OFFRAMP_USER/wink.svg new file mode 100644 index 0000000000..da7c7eb762 --- /dev/null +++ b/public/avatars/badge/OFFRAMP_USER/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/apple.svg b/public/avatars/basic/apple.svg new file mode 100644 index 0000000000..9c3a95c208 --- /dev/null +++ b/public/avatars/basic/apple.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/avocado.svg b/public/avatars/basic/avocado.svg new file mode 100644 index 0000000000..42ca50a40a --- /dev/null +++ b/public/avatars/basic/avocado.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/cactus.svg b/public/avatars/basic/cactus.svg new file mode 100644 index 0000000000..2eb05b484d --- /dev/null +++ b/public/avatars/basic/cactus.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/cloud.svg b/public/avatars/basic/cloud.svg new file mode 100644 index 0000000000..7feeea2eb9 --- /dev/null +++ b/public/avatars/basic/cloud.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/cube.svg b/public/avatars/basic/cube.svg new file mode 100644 index 0000000000..f1bb52520e --- /dev/null +++ b/public/avatars/basic/cube.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/donut.svg b/public/avatars/basic/donut.svg new file mode 100644 index 0000000000..8c92155bf5 --- /dev/null +++ b/public/avatars/basic/donut.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/drop.svg b/public/avatars/basic/drop.svg new file mode 100644 index 0000000000..da06e7decf --- /dev/null +++ b/public/avatars/basic/drop.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/egg.svg b/public/avatars/basic/egg.svg new file mode 100644 index 0000000000..1d9055e3c9 --- /dev/null +++ b/public/avatars/basic/egg.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/fish.svg b/public/avatars/basic/fish.svg new file mode 100644 index 0000000000..f083f41f26 --- /dev/null +++ b/public/avatars/basic/fish.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/flower.svg b/public/avatars/basic/flower.svg new file mode 100644 index 0000000000..f390fed5c0 --- /dev/null +++ b/public/avatars/basic/flower.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/frog.svg b/public/avatars/basic/frog.svg new file mode 100644 index 0000000000..98efc0ffe7 --- /dev/null +++ b/public/avatars/basic/frog.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/gem.svg b/public/avatars/basic/gem.svg new file mode 100644 index 0000000000..57ee0ca8e4 --- /dev/null +++ b/public/avatars/basic/gem.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/ghost.svg b/public/avatars/basic/ghost.svg new file mode 100644 index 0000000000..c0571723c6 --- /dev/null +++ b/public/avatars/basic/ghost.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/heart.svg b/public/avatars/basic/heart.svg new file mode 100644 index 0000000000..a0d79812b8 --- /dev/null +++ b/public/avatars/basic/heart.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/leaf.svg b/public/avatars/basic/leaf.svg new file mode 100644 index 0000000000..383f05e5e9 --- /dev/null +++ b/public/avatars/basic/leaf.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/moon.svg b/public/avatars/basic/moon.svg new file mode 100644 index 0000000000..58d927424d --- /dev/null +++ b/public/avatars/basic/moon.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/mushroom.svg b/public/avatars/basic/mushroom.svg new file mode 100644 index 0000000000..8526be7bb4 --- /dev/null +++ b/public/avatars/basic/mushroom.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/planet.svg b/public/avatars/basic/planet.svg new file mode 100644 index 0000000000..deddf29464 --- /dev/null +++ b/public/avatars/basic/planet.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/star.svg b/public/avatars/basic/star.svg new file mode 100644 index 0000000000..f0ed2e188a --- /dev/null +++ b/public/avatars/basic/star.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/basic/sun.svg b/public/avatars/basic/sun.svg new file mode 100644 index 0000000000..32354d7ce0 --- /dev/null +++ b/public/avatars/basic/sun.svg @@ -0,0 +1 @@ + diff --git a/src/components/Global/DotFaceAvatar.tsx b/src/components/Global/DotFaceAvatar.tsx deleted file mode 100644 index 3a2464bf6f..0000000000 --- a/src/components/Global/DotFaceAvatar.tsx +++ /dev/null @@ -1,206 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { useTranslations } from 'next-intl' -import { twMerge } from '@/utils/tw' - -/** - * Deterministic generative avatar for the user's OWN identity (home chip, - * self profile header). The username is hashed once; the hash picks one of - * the seven avatar colors, one of five eye styles, one of five mouths, and - * whether the cheeks blush — 875 combinations, stable across devices, - * nothing stored or uploaded. - * - * Deliberately NOT used for other people: counterparty rows keep the - * initials avatar, where letters genuinely help tell contacts apart. - * - * Colors come from the avatar board's seven triples (17802:61529) — the same - * set `AvatarWithBadge` draws initials on. Each triple travels together: the - * pale fill is the face, its border rims the circle, and its foreground draws - * the features and blush, so the face is legible at every size without a - * per-color contrast check. - */ -const PALETTE = ['pink', 'yellow', 'orange', 'blue', 'purple', 'red', 'green'] as const - -export type DotFaceColor = (typeof PALETTE)[number] - -// full literals so tailwind's scanner emits them; the fill/stroke pair cannot -// both ride currentColor, so the circle takes classes and the features inherit -const FACE_CLASSES: Record = { - pink: { - circle: 'fill-avatar-pink stroke-avatar-pink-border', - text: 'text-avatar-pink-foreground', - blush: 'fill-avatar-pink-border', - }, - yellow: { - circle: 'fill-avatar-yellow stroke-avatar-yellow-border', - text: 'text-avatar-yellow-foreground', - blush: 'fill-avatar-yellow-border', - }, - orange: { - circle: 'fill-avatar-orange stroke-avatar-orange-border', - text: 'text-avatar-orange-foreground', - blush: 'fill-avatar-orange-border', - }, - blue: { - circle: 'fill-avatar-blue stroke-avatar-blue-border', - text: 'text-avatar-blue-foreground', - blush: 'fill-avatar-blue-border', - }, - purple: { - circle: 'fill-avatar-purple stroke-avatar-purple-border', - text: 'text-avatar-purple-foreground', - blush: 'fill-avatar-purple-border', - }, - red: { - circle: 'fill-avatar-red stroke-avatar-red-border', - text: 'text-avatar-red-foreground', - blush: 'fill-avatar-red-border', - }, - green: { - circle: 'fill-avatar-green stroke-avatar-green-border', - text: 'text-avatar-green-foreground', - blush: 'fill-avatar-green-border', - }, -} - -const djb2 = (value: string): number => { - let hash = 5381 - for (let i = 0; i < value.length; i++) hash = ((hash << 5) + hash + value.charCodeAt(i)) >>> 0 - return hash -} - -/** Tiny LCG so trait draws stay reproducible from the single hash seed. */ -const lcg = (seed: number) => { - let state = seed >>> 0 - return () => { - state = (state * 1664525 + 1013904223) >>> 0 - return state / 4294967296 - } -} - -export interface DotFaceTraits { - background: DotFaceColor - eyeStyle: number - mouthStyle: number - blush: boolean -} - -export const dotFaceTraits = (username: string): DotFaceTraits => { - const next = lcg(djb2(username.toLowerCase())) - return { - background: PALETTE[Math.floor(next() * PALETTE.length)], - eyeStyle: Math.floor(next() * 5), - mouthStyle: Math.floor(next() * 5), - blush: next() > 0.45, - } -} - -const DotFaceAvatar = ({ username, size, className }: { username: string; size?: number; className?: string }) => { - const t = useTranslations('common') - const traits = useMemo(() => dotFaceTraits(username), [username]) - const { background, eyeStyle, mouthStyle, blush } = traits - const face = FACE_CLASSES[background] - - const cx = 50 - const cy = 46 - const sc = 1.5 - const ex = 13 * sc - const ey = cy - 4 * sc - const my = cy + 9 * sc - - const eyes = [ - - - - , - - - - , - - - - , - - - - - , - - - - , - ][eyeStyle] - - const mouths = [ - , - , - , - , - , - ][mouthStyle] - - return ( - - - - {eyes} - {mouths} - {blush && ( - - - - - )} - - - ) -} - -export default DotFaceAvatar diff --git a/src/components/Global/__tests__/DotFaceAvatar.test.tsx b/src/components/Global/__tests__/DotFaceAvatar.test.tsx deleted file mode 100644 index dc57cace72..0000000000 --- a/src/components/Global/__tests__/DotFaceAvatar.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** @jest-environment jsdom */ -/** - * DotFaceAvatar — the generated self-avatar. - * - * Contract: fully deterministic from the username (same input, same face, - * every render and device), case-insensitive, and distinct inputs are free - * to differ. Only used for the user's own identity; counterparties keep - * the initials avatar. - */ -import React from 'react' -import { render as rtlRender, screen } from '@testing-library/react' -import { IntlWrapper } from '@/test-utils/intl' -import DotFaceAvatar, { dotFaceTraits } from '@/components/Global/DotFaceAvatar' - -const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) - -describe('DotFaceAvatar', () => { - it('is deterministic and case-insensitive for the same username', () => { - expect(dotFaceTraits('test022')).toEqual(dotFaceTraits('TEST022')) - const { container: a } = render() - const { container: b } = render() - expect(a.innerHTML).toBe(b.innerHTML) - }) - - it('derives every trait from the palette and trait ranges', () => { - for (const name of ['alice', 'bob88', 'mariana.br', 'x']) { - const t = dotFaceTraits(name) - // the avatar board's seven triples — the same set the initials - // avatar uses, so the two never clash side by side - expect(['pink', 'yellow', 'orange', 'blue', 'purple', 'red', 'green']).toContain(t.background) - expect(t.eyeStyle).toBeGreaterThanOrEqual(0) - expect(t.eyeStyle).toBeLessThan(5) - expect(t.mouthStyle).toBeGreaterThanOrEqual(0) - expect(t.mouthStyle).toBeLessThan(5) - } - }) - - it('renders an accessible image named after the user', () => { - render() - expect(screen.getByRole('img', { name: 'Avatar for test022' })).toBeInTheDocument() - }) -}) diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json index 4696a123fd..6bd2d5ebbe 100644 --- a/src/types/badge-assets.json +++ b/src/types/badge-assets.json @@ -54,5 +54,33 @@ "TRON": "/badges/tron.svg", "VERIFIED": "/badges/verified.svg", "WAITLIST_SKIP": "/badges/skip_pass.svg" + }, + "avatars": { + "basics": [ + "apple", + "avocado", + "cactus", + "cloud", + "cube", + "donut", + "drop", + "egg", + "fish", + "flower", + "frog", + "gem", + "ghost", + "heart", + "leaf", + "moon", + "mushroom", + "planet", + "star", + "sun" + ], + "badges": { + "BUG_WHISPERER": ["beetle", "shell", "peek"], + "OFFRAMP_USER": ["bolt", "spark", "wink"] + } } } From 1bb0ea576f6735563c4546147bf9ae8f7bf57090 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 22:46:04 +0100 Subject: [PATCH 02/17] feat(avatars): wear the picked avatar, fall back to one username initial (TASK-22142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated dot face is gone: it was not launch-ready and drew on name strings. UserAvatar renders the user's picked character on its palette, or exactly one character of the username on the username's color — never the full name or verification data. The dead UserHeader component goes with it. --- src/components/Avatar/UserAvatar.tsx | 70 +++++++++++++++++++ .../Avatar/__tests__/UserAvatar.test.tsx | 42 +++++++++++ .../Avatar/__tests__/avatar.utils.test.ts | 50 +++++++++++++ src/components/Avatar/avatar.utils.ts | 41 +++++++++++ .../Profile/components/ProfileHeader.tsx | 29 ++++++-- src/components/UserHeader/index.tsx | 27 ------- src/features/home/HomePage.tsx | 4 +- .../home/__tests__/useHomeFlow.test.ts | 17 ++--- src/features/home/useHomeFlow.ts | 9 ++- src/features/home/views/HomeTopNav.tsx | 28 +++----- .../home/views/__tests__/HomeTopNav.test.tsx | 22 ++++-- src/i18n/app/messages/en.json | 16 ++++- src/i18n/app/messages/es-419.json | 16 ++++- src/i18n/app/messages/pt-BR.json | 16 ++++- src/interfaces/interfaces.ts | 3 + 15 files changed, 312 insertions(+), 78 deletions(-) create mode 100644 src/components/Avatar/UserAvatar.tsx create mode 100644 src/components/Avatar/__tests__/UserAvatar.test.tsx create mode 100644 src/components/Avatar/__tests__/avatar.utils.test.ts create mode 100644 src/components/Avatar/avatar.utils.ts diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx new file mode 100644 index 0000000000..7914e069ac --- /dev/null +++ b/src/components/Avatar/UserAvatar.tsx @@ -0,0 +1,70 @@ +'use client' + +import Image from 'next/image' +import { useTranslations } from 'next-intl' +import { Icon } from '@/components/Global/Icons/Icon' +import type { AvatarSize } from '@/components/Profile/AvatarWithBadge' +import { getColorForUsername } from '@/utils/color.utils' +import { twMerge } from '@/utils/tw' +import { avatarPalette, avatarSrc } from './avatar.utils' + +interface UserAvatarProps { + /** The username only. The fallback is its first character — never the + * full name or anything from verification, which would leak PII. */ + username?: string + avatarKey?: string | null + size?: AvatarSize + className?: string +} + +// board 17802:61529: XS 24 · S 32 · M 48 · L 64 (+ the code-only 96), circle, +// 1px border. Same boxes and type steps as AvatarWithBadge. +const SIZE_CLASSES: Record = { + tiny: 'size-6 text-label-m', + 'extra-small': 'size-8 text-label-m', + small: 'size-12 text-body-m-semibold', + medium: 'size-16 text-heading-s', + large: 'size-24 text-heading-m', +} +const SIZE_PX: Record = { tiny: 24, 'extra-small': 32, small: 48, medium: 64, large: 96 } +const ICON_PX: Record = { tiny: 12, 'extra-small': 16, small: 18, medium: 32, large: 48 } + +/** + * The user's own avatar: their picked character on its palette triple, or + * the privacy-safe fallback — one username initial on the username's color. + */ +export function UserAvatar({ username, avatarKey, size = 'extra-small', className }: UserAvatarProps) { + const t = useTranslations('common') + const src = avatarSrc(avatarKey) + const initial = username?.trim().charAt(0).toUpperCase() || '' + // the seven triples travel together (fill, border, foreground); no-username + // resolves to the palette's yellow default, like the rest of the app + const palette = src && avatarKey ? avatarPalette(avatarKey) : getColorForUsername(initial ? username : undefined) + + return ( + + {src ? ( + + ) : initial ? ( + initial + ) : ( + + )} + + ) +} diff --git a/src/components/Avatar/__tests__/UserAvatar.test.tsx b/src/components/Avatar/__tests__/UserAvatar.test.tsx new file mode 100644 index 0000000000..a57a7dfbf2 --- /dev/null +++ b/src/components/Avatar/__tests__/UserAvatar.test.tsx @@ -0,0 +1,42 @@ +import { screen } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { renderWithIntl } from '@/test-utils/intl' +import { UserAvatar } from '../UserAvatar' + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean }) => , +})) + +describe('UserAvatar', () => { + it('renders the picked character on its palette', () => { + renderWithIntl() + + expect(screen.getByRole('img', { name: 'Avatar for satoshi' })).toBeInTheDocument() + expect(document.querySelector('img')).toHaveAttribute('src', '/avatars/basic/frog.svg') + expect(screen.queryByText('S')).not.toBeInTheDocument() + }) + + // privacy-safe fallback: exactly one character of the USERNAME. The + // component has no name/fullName prop at all, so verification data cannot + // reach it — a username that looks like a full name still yields one letter. + it('falls back to a single username initial, never more', () => { + renderWithIntl() + + expect(screen.getByRole('img', { name: 'Avatar for Satoshi Nakamoto' })).toHaveTextContent(/^S$/) + expect(document.querySelector('img')).toBeNull() + }) + + it('treats a key the manifest does not know as no pick', () => { + renderWithIntl() + + expect(screen.getByRole('img', { name: 'Avatar for hal' })).toHaveTextContent(/^H$/) + }) + + it('shows the generic user glyph with no username at all', () => { + const { container } = renderWithIntl() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) +}) diff --git a/src/components/Avatar/__tests__/avatar.utils.test.ts b/src/components/Avatar/__tests__/avatar.utils.test.ts new file mode 100644 index 0000000000..ff21de3e51 --- /dev/null +++ b/src/components/Avatar/__tests__/avatar.utils.test.ts @@ -0,0 +1,50 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import badgeAssets from '@/types/badge-assets.json' +import { avatarPalette, avatarPool, avatarSrc, badgeAvatarKeys, basicAvatarKeys } from '../avatar.utils' + +describe('avatar catalog', () => { + // the manifest is the API's contract: every slug it names must be real art + // here, or the picker shows a broken image the day the API ships the badge + it('resolves every avatar the manifest declares to a file under public/avatars', () => { + const paths = [ + ...badgeAssets.avatars.basics.map((slug) => `/avatars/basic/${slug}.svg`), + ...Object.entries(badgeAssets.avatars.badges).flatMap(([code, slugs]) => + slugs.map((slug) => `/avatars/badge/${code}/${slug}.svg`) + ), + ] + expect(paths.length).toBeGreaterThanOrEqual(26) + for (const path of paths) { + expect({ path, exists: existsSync(join(process.cwd(), 'public', path)) }).toEqual({ path, exists: true }) + } + }) + + it('gives everyone the twenty basics and nothing from badges they do not hold', () => { + expect(basicAvatarKeys()).toHaveLength(20) + expect(basicAvatarKeys()[0]).toBe('basic.apple') + expect(badgeAvatarKeys([])).toEqual([]) + expect(badgeAvatarKeys(['FIRST_INVITE', 'NOT_A_BADGE'])).toEqual([]) + expect(badgeAvatarKeys(['BUG_WHISPERER'])).toEqual([ + 'badge.BUG_WHISPERER.beetle', + 'badge.BUG_WHISPERER.shell', + 'badge.BUG_WHISPERER.peek', + ]) + expect(avatarPool(['OFFRAMP_USER'])).toHaveLength(23) + }) + + it('maps keys to their art and rejects anything the manifest does not know', () => { + expect(avatarSrc('basic.apple')).toBe('/avatars/basic/apple.svg') + expect(avatarSrc('badge.OFFRAMP_USER.wink')).toBe('/avatars/badge/OFFRAMP_USER/wink.svg') + expect(avatarSrc('basic.peanut')).toBeNull() + expect(avatarSrc('badge.BUG_WHISPERER.nope')).toBeNull() + expect(avatarSrc('badge.FIRST_INVITE.beetle')).toBeNull() + expect(avatarSrc('../etc/passwd')).toBeNull() + expect(avatarSrc(null)).toBeNull() + expect(avatarSrc(undefined)).toBeNull() + }) + + it('keeps a stable palette per key', () => { + expect(avatarPalette('basic.apple')).toEqual(avatarPalette('basic.apple')) + expect(avatarPalette('basic.apple')).toHaveProperty('lightShade') + }) +}) diff --git a/src/components/Avatar/avatar.utils.ts b/src/components/Avatar/avatar.utils.ts new file mode 100644 index 0000000000..4a8b934ac9 --- /dev/null +++ b/src/components/Avatar/avatar.utils.ts @@ -0,0 +1,41 @@ +/** + * Profile avatars (TASK-22142). The catalog is the API's badge manifest + * (`badge-assets.json`, regenerated by `pnpm badge:check --write-manifest` + * in peanut-api-ts — never hand-edited): `avatars.basics` is the set every + * user gets, `avatars.badges[CODE]` the slugs holding that badge unlocks. + * + * Keys are `basic.` and `badge..`; the API validates a + * pick against the same pool, so this file never decides who may wear what — + * it only mirrors the manifest into paths and palettes. + */ +import badgeAssets from '@/types/badge-assets.json' +import { getColorForUsername } from '@/utils/color.utils' + +const BASICS: readonly string[] = badgeAssets.avatars.basics +const BADGE_AVATARS: Readonly> = badgeAssets.avatars.badges + +export const basicAvatarKeys = (): string[] => BASICS.map((slug) => `basic.${slug}`) + +/** Avatar keys unlocked by holding these badge codes, in badge order. */ +export const badgeAvatarKeys = (heldCodes: readonly string[]): string[] => + heldCodes.flatMap((code) => (BADGE_AVATARS[code] ?? []).map((slug) => `badge.${code}.${slug}`)) + +/** Everything the user may pick: the basics plus what their badges unlock. */ +export const avatarPool = (heldCodes: readonly string[]): string[] => [ + ...basicAvatarKeys(), + ...badgeAvatarKeys(heldCodes), +] + +/** Public path of the avatar art, or null for a key the manifest does not know. */ +export function avatarSrc(key: string | null | undefined): string | null { + if (!key) return null + const [kind, ...rest] = key.split('.') + if (kind === 'basic' && rest.length === 1 && BASICS.includes(rest[0])) return `/avatars/basic/${rest[0]}.svg` + if (kind === 'badge' && rest.length === 2 && BADGE_AVATARS[rest[0]]?.includes(rest[1])) { + return `/avatars/badge/${rest[0]}/${rest[1]}.svg` + } + return null +} + +/** One of the seven avatar triples (board 17802:61529), stable per key. */ +export const avatarPalette = (key: string) => getColorForUsername(key) diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index 37b29d44ec..06f0aae23d 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -6,8 +6,9 @@ import posthog from 'posthog-js' import React, { useEffect, useRef } from 'react' import { twMerge } from '@/utils/tw' import AvatarWithBadge from '../AvatarWithBadge' -import DotFaceAvatar from '@/components/Global/DotFaceAvatar' +import { UserAvatar } from '@/components/Avatar/UserAvatar' import { VerifiedUserLabel } from '@/components/UserHeader' +import { useTranslations } from 'next-intl' import { useAuth } from '@/context/authContext' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import CopyToClipboard from '@/components/Global/CopyToClipboard' @@ -21,6 +22,8 @@ interface ProfileHeaderProps { className?: string showShareButton?: boolean haveSentMoneyToUser?: boolean + /** Self profile only: makes the avatar a button that opens the picker. */ + onChangeAvatar?: () => void } const ProfileHeader: React.FC = ({ @@ -30,8 +33,10 @@ const ProfileHeader: React.FC = ({ className, showShareButton = true, haveSentMoneyToUser = false, + onChangeAvatar, }) => { const { user: authenticatedUser } = useAuth() + const tAvatar = useTranslations('avatar') // The self-profile verified badge means "this person's ID was confirmed" — // NOT "this person has an enabled payment rail." It reads identityVerification // (Sumsub-cleared), matching the counterparty badge logic (`isVerified` on @@ -63,10 +68,26 @@ const ProfileHeader: React.FC = ({ return ( <>
- {/* Own profile gets the generated dot face; someone else's - public profile keeps initials (letters identify others). */} + {/* Own profile wears the picked avatar (or one username initial) + at Avatar L; someone else's public profile keeps initials + (letters identify others). */} {isSelfProfile ? ( - + onChangeAvatar ? ( + + ) : ( + + ) ) : ( )} diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index d39aa56d53..d927512697 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -1,40 +1,13 @@ 'use client' -import DotFaceAvatar from '@/components/Global/DotFaceAvatar' -import Link from 'next/link' import { Icon } from '../Global/Icons/Icon' import { twMerge } from '@/utils/tw' import { Tooltip } from '../Tooltip' import { useMemo } from 'react' import { useAuth } from '@/context/authContext' import AddressLink from '../Global/AddressLink' -import { Button } from '@/components/0_Bruddle/Button' import { isCryptoAddress } from '@/utils/general.utils' -interface UserHeaderProps { - username: string - fullName?: string - isVerified?: boolean -} - -export const UserHeader = ({ username }: UserHeaderProps) => { - return ( - - - - ) -} - /** * Default type for the name. It has to be a type TOKEN, not a bare weight * class: a caller passing `text-heading-s` (ProfileHeader does) merges with a diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx index c85eb15713..6d13e74ab6 100644 --- a/src/features/home/HomePage.tsx +++ b/src/features/home/HomePage.tsx @@ -27,7 +27,7 @@ export function HomePage() { const { isPageLoading, username, - avatarName, + avatarKey, isActivated, activationStep, dismissCardStep, @@ -45,7 +45,7 @@ export function HomePage() { return (
- + { expect(mockDisconnect).not.toHaveBeenCalled() }) - it('derives avatarName from the showFullName preference', () => { - mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' }) - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') - - mockUser = userWith({ showFullName: false, fullName: 'Kushagra S' }) - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('kush') + it('passes the picked avatar through and never derives one from the full name', () => { + mockUser = userWith({ avatarKey: 'basic.frog', showFullName: true, fullName: 'Kushagra S' }) + expect(renderHook(() => useHomeFlow()).result.current.avatarKey).toBe('basic.frog') - // usernameless: full name still seeds the initials - mockUser = { user: { userId: 'u1', username: null, fullName: 'Kushagra S' } } - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') - - mockUser = { user: { userId: 'u1', username: null } } - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBeUndefined() + mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' }) + expect(renderHook(() => useHomeFlow()).result.current.avatarKey).toBeNull() }) }) diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts index 128727bb61..e926096988 100644 --- a/src/features/home/useHomeFlow.ts +++ b/src/features/home/useHomeFlow.ts @@ -51,15 +51,14 @@ export function useHomeFlow() { } }, [isWagmiConnected, disconnectWagmi]) - // respect the showFullName preference for the avatar initials; a - // usernameless user still gets initials from their full name (initials - // only — the preference governs showing the full name, not its initials) - const avatarName = (user?.user.showFullName && user?.user.fullName) || username || user?.user.fullName || undefined + // the picked avatar (TASK-22142); the top nav falls back to one username + // initial, never to the full name, so nothing else is derived here + const avatarKey = user?.user.avatarKey ?? null return { isPageLoading: isFetchingUser && !username, username, - avatarName, + avatarKey, isActivated, activationStep, dismissCardStep, diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx index f1430c7319..d6dd96df62 100644 --- a/src/features/home/views/HomeTopNav.tsx +++ b/src/features/home/views/HomeTopNav.tsx @@ -1,15 +1,15 @@ 'use client' +import { UserAvatar } from '@/components/Avatar/UserAvatar' import { Icon } from '@/components/Global/Icons/Icon' import InvitesIcon from '@/components/Home/InvitesIcon' -import DotFaceAvatar from '@/components/Global/DotFaceAvatar' -import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import { useAppHaptic } from '@/hooks/useAppHaptic' import { useAppTranslations } from '@/i18n/app/useAppTranslations' import Link from 'next/link' interface HomeTopNavProps { - avatarName?: string + username?: string + avatarKey?: string | null showRewards: boolean } @@ -18,7 +18,7 @@ interface HomeTopNavProps { * top-left linking to /profile (Vlad follow-up: one size down from 48), * rewards link top-right. The link keeps a 44px hit area via after: inset. */ -export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) { +export function HomeTopNav({ username, avatarKey, showRewards }: HomeTopNavProps) { const t = useAppTranslations('home') const { triggerHaptic } = useAppHaptic() @@ -31,22 +31,10 @@ export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) { className="relative block after:absolute after:-inset-1.5" aria-label={t('openProfile')} > - {/* The generated face, which is what DotFaceAvatar exists for — - own identity, here and on the profile header. This chip was - rebuilt onto initials during the nav consolidation and the - face was left behind in UserHeader, which nothing renders. - A user with no name string at all still gets an avatar-toned - circle (yellow — the palette's no-name default). */} - {avatarName ? ( - - ) : ( - - )} + {/* The user's picked avatar (TASK-22142), or the privacy-safe + fallback: one username initial on its color. Never the full + name — that is verification data. */} + {showRewards && ( ({ useAppHaptic: () => ({ triggerHaptic: jest.fn() }) })) jest.mock('@/components/Home/InvitesIcon', () => ({ __esModule: true, default: () => null })) +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean }) => , +})) describe('HomeTopNav', () => { - it('wears the generated face, not initials — this chip is the user own identity', () => { - const { container } = renderWithIntl() + it('wears the picked avatar inside the profile link', () => { + const { container } = renderWithIntl( + + ) - // DotFaceAvatar draws an svg; the initials avatar would render the letters - expect(container.querySelector('a[href="/profile"] svg')).toBeInTheDocument() + expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/basic/frog.svg') + }) + + it('falls back to one username initial, not the name initials', () => { + const { container } = renderWithIntl() + + expect(container.querySelector('a[href="/profile"]')).toHaveTextContent(/^T$/) expect(screen.queryByText(/^TE$/i)).not.toBeInTheDocument() }) - it('falls back to the no-name circle when there is no username yet', () => { + it('still links to the profile when there is no username yet', () => { const { container } = renderWithIntl() expect(container.querySelector('a[href="/profile"]')).toBeInTheDocument() diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 785fa8bde7..0a80de14b6 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2462,7 +2462,9 @@ "name": "Skip Pass", "description": "You skipped the waitlist. A friend handed you the key and you walked right in." } - } + }, + "toastAvatars": "{count, plural, one {# new avatar unlocked} other {# new avatars unlocked}}", + "toastChooseAvatar": "Choose avatar" }, "notifications": { "setupTitle": "Turn on notifications?", @@ -3578,5 +3580,17 @@ "android": "Update Android System WebView" }, "continueAnyway": "Continue anyway" + }, + "avatar": { + "title": "Your avatar", + "description": "Pick one, or roll the die across everything you have unlocked.", + "fromBadges": "From your badges", + "basics": "Basics", + "unlocked": "{count} unlocked", + "noBadgeAvatars": "Earn a badge and its avatars appear here.", + "roll": "Roll the die", + "useInitial": "Use my initial instead", + "change": "Change avatar", + "saveFailed": "Could not save your avatar. Try again." } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index e5e346aa58..a36ab0fa05 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2462,7 +2462,9 @@ "name": "Pase directo", "description": "Te saltaste la lista de espera. Un amigo te dio la llave y entraste directo." } - } + }, + "toastAvatars": "{count, plural, one {# avatar nuevo desbloqueado} other {# avatares nuevos desbloqueados}}", + "toastChooseAvatar": "Elegir avatar" }, "notifications": { "setupTitle": "¿Activar las notificaciones?", @@ -3578,5 +3580,17 @@ "android": "Actualizar Android System WebView" }, "continueAnyway": "Continuar de todos modos" + }, + "avatar": { + "title": "Tu avatar", + "description": "Elige uno o tira el dado entre todo lo que desbloqueaste.", + "fromBadges": "De tus insignias", + "basics": "Básicos", + "unlocked": "{count} desbloqueados", + "noBadgeAvatars": "Gana una insignia y sus avatares aparecen aquí.", + "roll": "Tirar el dado", + "useInitial": "Usar mi inicial", + "change": "Cambiar avatar", + "saveFailed": "No pudimos guardar tu avatar. Inténtalo de nuevo." } } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 4dec3f16e6..f0d342fe40 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2462,7 +2462,9 @@ "name": "Passe livre", "description": "Você pulou a lista de espera. Um amigo te deu a chave e você entrou direto." } - } + }, + "toastAvatars": "{count, plural, one {# avatar novo desbloqueado} other {# avatares novos desbloqueados}}", + "toastChooseAvatar": "Escolher avatar" }, "notifications": { "setupTitle": "Ativar as notificações?", @@ -3578,5 +3580,17 @@ "android": "Atualizar o Android System WebView" }, "continueAnyway": "Continuar mesmo assim" + }, + "avatar": { + "title": "Seu avatar", + "description": "Escolha um ou jogue o dado entre tudo o que você desbloqueou.", + "fromBadges": "Dos seus selos", + "basics": "Básicos", + "unlocked": "{count} desbloqueados", + "noBadgeAvatars": "Ganhe um selo e seus avatares aparecem aqui.", + "roll": "Jogar o dado", + "useInitial": "Usar minha inicial", + "change": "Trocar avatar", + "saveFailed": "Não foi possível salvar seu avatar. Tente de novo." } } diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 78210f78f6..a73e7f274e 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -166,6 +166,9 @@ export interface User { userId: string email: string profile_picture: string | null + /** Picked profile avatar, `basic.` or `badge..`; + * null means the username-initial fallback (TASK-22142). */ + avatarKey?: string | null username: string | null bridgeCustomerId: string | null fullName: string From a164fb91a96148c6fc67cc8caab60df14ebc8a47 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 22:46:04 +0100 Subject: [PATCH 03/17] feat(avatars): picker drawer with a die, badge-earned toast hands off to it (TASK-22142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two groups — what the user's badges unlocked, then the basics — a tap saves at once, the die randomizes across the whole pool for free, and a badge that ships avatars says so in the earn toast with a Choose avatar control that deep-links to /profile?avatarPicker=true (nuqs URL state). Fixture routes may now carry their own query so ds-shots can capture the open picker. --- e2e/shots/fixtures.spec.ts | 3 +- src/app/dev/fixtures/FixtureList.tsx | 3 +- src/components/Avatar/AvatarPicker.tsx | 141 ++++++++++++++++++ .../Avatar/__tests__/AvatarPicker.test.tsx | 113 ++++++++++++++ src/components/Badges/BadgeEarnToast.tsx | 48 ++++-- .../Badges/__tests__/BadgeEarnToast.test.tsx | 32 +++- src/components/Profile/index.tsx | 15 +- src/dev/fixtures/__tests__/fixtures.test.ts | 3 +- src/dev/fixtures/registry.ts | 32 ++++ 9 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 src/components/Avatar/AvatarPicker.tsx create mode 100644 src/components/Avatar/__tests__/AvatarPicker.test.tsx diff --git a/e2e/shots/fixtures.spec.ts b/e2e/shots/fixtures.spec.ts index ffdfdfc877..9bba825ae2 100644 --- a/e2e/shots/fixtures.spec.ts +++ b/e2e/shots/fixtures.spec.ts @@ -110,7 +110,8 @@ for (const [name, fixture] of Object.entries(FIXTURES)) { await page.clock.setFixedTime(FROZEN_NOW) await page.addInitScript(seenOnceModals) - await page.goto(`${fixture.route}?${FIXTURE_PARAM}=${name}`, { waitUntil: 'domcontentloaded' }) + const sep = fixture.route.includes('?') ? '&' : '?' + await page.goto(`${fixture.route}${sep}${FIXTURE_PARAM}=${name}`, { waitUntil: 'domcontentloaded' }) await settle(page) // A build without NEXT_PUBLIC_VERCEL_ENV=preview ignores the param and diff --git a/src/app/dev/fixtures/FixtureList.tsx b/src/app/dev/fixtures/FixtureList.tsx index a23ebb34d4..520f2b7fd2 100644 --- a/src/app/dev/fixtures/FixtureList.tsx +++ b/src/app/dev/fixtures/FixtureList.tsx @@ -20,7 +20,8 @@ export default function FixtureList() { const fixture = FIXTURES[name] // Plain : a soft navigation would keep the previous fixture's // React Query cache, so the screen would show stale state. - const href = `${fixture.route}?${FIXTURE_PARAM}=${name}` + // a route may carry its own query (nuqs URL state) + const href = `${fixture.route}${fixture.route.includes('?') ? '&' : '?'}${FIXTURE_PARAM}=${name}` return ( diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx new file mode 100644 index 0000000000..e372999f92 --- /dev/null +++ b/src/components/Avatar/AvatarPicker.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useEffect, useState, type KeyboardEvent } from 'react' +import { useTranslations } from 'next-intl' +import { updateUserById } from '@/app/actions/users' +import { Button } from '@/components/0_Bruddle/Button' +import { useToast } from '@/components/0_Bruddle/Toast' +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' +import { useAuth } from '@/context/authContext' +import { twMerge } from '@/utils/tw' +import { avatarPool, badgeAvatarKeys, basicAvatarKeys } from './avatar.utils' +import { UserAvatar } from './UserAvatar' + +interface AvatarPickerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +const COLUMNS = 5 + +// one tab stop per group, arrows move, wrapping (radiogroup convention) +function rove(event: KeyboardEvent) { + const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLUMNS, ArrowUp: -COLUMNS }[event.key] + if (!step) return + const radios = Array.from(event.currentTarget.querySelectorAll('[role="radio"]')) + const index = radios.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + event.preventDefault() + radios[(index + step + radios.length) % radios.length].focus() +} + +/** + * The profile avatar picker (TASK-22142): what the user's badges unlocked, + * then the basics everyone has. A tap saves at once; the die randomizes + * across the whole pool, free forever; "use my initial" clears the pick. + * The API validates the pick against the same pool, so a locked key never + * lands even if the manifest and the catalog drift. + */ +export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { + const t = useTranslations('avatar') + const tCommon = useTranslations('common') + const { user, fetchUser } = useAuth() + const { toast } = useToast() + + const userId = user?.user.userId + const username = user?.user.username ?? undefined + const saved = user?.user.avatarKey ?? null + const held = (user?.user.badges ?? []).map((badge) => badge.code) + const unlocked = badgeAvatarKeys(held) + const basics = basicAvatarKeys() + + // optimistic: the tile and the slot behind the drawer move on tap; a + // failed save snaps back and says so + const [pick, setPick] = useState(saved) + useEffect(() => setPick(saved), [saved]) + + const save = async (key: string | null) => { + if (!userId) return + const previous = pick + setPick(key) + const { error } = await updateUserById({ userId, avatarKey: key }) + if (error) { + setPick(previous) + toast({ type: 'error', message: t('saveFailed') }) + return + } + await fetchUser() + } + + const roll = () => { + const pool = avatarPool(held).filter((key) => key !== pick) + if (pool.length === 0) return + void save(pool[Math.floor(Math.random() * pool.length)]) + } + + const tiles = (keys: string[], label: string) => ( +
+ {keys.map((key, index) => { + const checked = key === pick + return ( + + ) + })} +
+ ) + + return ( + + + + {t('title')} + {t('description')} + +
+
+
+ {t('fromBadges')} + {unlocked.length > 0 && ( + {t('unlocked', { count: unlocked.length })} + )} +
+ {unlocked.length > 0 ? ( + tiles(unlocked, t('fromBadges')) + ) : ( +

{t('noBadgeAvatars')}

+ )} +
+
+
{t('basics')}
+ {tiles(basics, t('basics'))} +
+
+ + + +
+
+
+
+ ) +} diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx new file mode 100644 index 0000000000..54143e84e9 --- /dev/null +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import type { ComponentProps, ReactNode } from 'react' +import { renderWithIntl } from '@/test-utils/intl' +import { AvatarPicker } from '../AvatarPicker' +import { avatarPool } from '../avatar.utils' + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean }) => , +})) + +// vaul needs a real layout; the picker's own logic is what is under test +jest.mock('@/components/Global/Drawer', () => { + const Passthrough = ({ children }: { children?: ReactNode }) =>
{children}
+ return { + Drawer: ({ open, children }: { open: boolean; children?: ReactNode }) => (open ?
{children}
: null), + DrawerContent: Passthrough, + DrawerHeader: Passthrough, + DrawerTitle: Passthrough, + DrawerDescription: Passthrough, + } +}) + +const mockToast = jest.fn() +jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ toast: mockToast }) })) + +const mockUpdateUserById = jest.fn() +jest.mock('@/app/actions/users', () => ({ updateUserById: (...args: unknown[]) => mockUpdateUserById(...args) })) + +const mockFetchUser = jest.fn() +let mockUser: { user: { userId: string; username: string; avatarKey: string | null; badges: { code: string }[] } } +jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: mockUser, fetchUser: mockFetchUser }) })) + +const radio = (key: string) => screen.getByRole('radio', { name: key }) + +beforeEach(() => { + jest.clearAllMocks() + mockUpdateUserById.mockResolvedValue({ data: {} }) + mockFetchUser.mockResolvedValue(null) + mockUser = { user: { userId: 'u1', username: 'satoshi', avatarKey: null, badges: [{ code: 'BUG_WHISPERER' }] } } +}) + +describe('AvatarPicker', () => { + it('lists the basics and only the avatars of badges the user holds', () => { + renderWithIntl() + + expect(screen.getAllByRole('radio')).toHaveLength(23) + expect(radio('badge.BUG_WHISPERER.beetle')).toBeInTheDocument() + expect(screen.queryByRole('radio', { name: 'badge.OFFRAMP_USER.bolt' })).not.toBeInTheDocument() + expect(screen.getByRole('radiogroup', { name: 'From your badges' })).toBeInTheDocument() + }) + + it('tells a user with no badges where avatars come from', () => { + mockUser.user.badges = [] + renderWithIntl() + + expect(screen.getAllByRole('radio')).toHaveLength(20) + expect(screen.getByText('Earn a badge and its avatars appear here.')).toBeInTheDocument() + }) + + it('saves a tap at once and refreshes the user', async () => { + renderWithIntl() + + fireEvent.click(radio('badge.BUG_WHISPERER.peek')) + + expect(radio('badge.BUG_WHISPERER.peek')).toHaveAttribute('aria-checked', 'true') + expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: 'badge.BUG_WHISPERER.peek' }) + await waitFor(() => expect(mockFetchUser).toHaveBeenCalled()) + }) + + it('snaps back and says so when the save fails', async () => { + mockUser.user.avatarKey = 'basic.sun' + mockUpdateUserById.mockResolvedValue({ error: 'Avatar not unlocked' }) + renderWithIntl() + + fireEvent.click(radio('basic.apple')) + + await waitFor(() => expect(radio('basic.sun')).toHaveAttribute('aria-checked', 'true')) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(mockFetchUser).not.toHaveBeenCalled() + }) + + it('rolls the die inside the unlocked pool and never lands on the current pick', () => { + mockUser.user.avatarKey = 'basic.apple' + jest.spyOn(Math, 'random').mockReturnValue(0) + renderWithIntl() + + act(() => fireEvent.click(screen.getByRole('button', { name: 'Roll the die' }))) + + const key = mockUpdateUserById.mock.calls[0][0].avatarKey as string + expect(avatarPool(['BUG_WHISPERER'])).toContain(key) + expect(key).not.toBe('basic.apple') + ;(Math.random as jest.Mock).mockRestore() + }) + + it('clears the pick back to the initial', () => { + mockUser.user.avatarKey = 'basic.apple' + renderWithIntl() + + fireEvent.click(screen.getByRole('button', { name: 'Use my initial instead' })) + + expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: null }) + }) + + it('closes on done', () => { + const onOpenChange = jest.fn() + renderWithIntl() + + fireEvent.click(screen.getByRole('button', { name: 'Done' })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx index c6dc6c8b90..1f2d22188e 100644 --- a/src/components/Badges/BadgeEarnToast.tsx +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -27,8 +27,11 @@ import { useBadgeCopy } from '@/components/Badges/useBadgeCopy' import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { BadgeImage } from '@/components/Badges/BadgeImage' +import { badgeAvatarKeys } from '@/components/Avatar/avatar.utils' const HOME_PATH = '/home' +// the picker opens from the profile page; `avatarPicker` is its nuqs URL state +const AVATAR_PICKER_PATH = '/profile?avatarPicker=true' type ModalBadge = { code: string; title: string; description: string; logo: string } @@ -81,6 +84,17 @@ export default function BadgeEarnToast() { const label = count === 1 ? t('toastSingle', { name: newestName }) : t('toastMultiple', { count }) + // A badge that ships avatars (TASK-22142) announces the unlock and + // hands the user straight to the picker — the badge tap keeps its + // detail view, so the two are separate controls, not one. + const avatarCount = badgeAvatarKeys(codes).length + const chooseAvatar = () => { + dismiss(toastId) + liveToastIdRef.current = null + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count, target: 'avatar_picker' }) + router.push(AVATAR_PICKER_PATH) + } + toast({ id: toastId, type: 'success', @@ -88,19 +102,27 @@ export default function BadgeEarnToast() { className: 'border-action-secondary bg-background-default', hideIcon: true, content: ( - +
+ + {avatarCount > 0 && ( + + )} +
), }) liveToastIdRef.current = toastId diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx index 4f877f9920..2ab30069f3 100644 --- a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -1,4 +1,4 @@ -import { render as rtlRender, screen, act } from '@testing-library/react' +import { render as rtlRender, screen, act, fireEvent } from '@testing-library/react' import { IntlWrapper } from '@/test-utils/intl' import type { ComponentProps } from 'react' import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' @@ -91,8 +91,8 @@ describe('BadgeEarnToast', () => { expect(mockMarkSeen).toHaveBeenCalledWith(['PRODUCT_HUNT']) expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_shown', { count: 1 }) - const content = mockToast.mock.calls[0][0].content - act(() => content.props.onClick()) + render(mockToast.mock.calls[0][0].content) + act(() => fireEvent.click(screen.getByRole('button', { name: /tap to view/ }))) expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT') expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_tapped', { count: 1 }) @@ -119,6 +119,27 @@ describe('BadgeEarnToast', () => { expect(screen.getByText(/Backend Name/)).toBeInTheDocument() }) + it('announces unlocked avatars and hands the user to the picker (TASK-22142)', () => { + mockPending = [badge('BUG_WHISPERER', 'Bug Whisperer')] + render() + + render(mockToast.mock.calls[0][0].content) + expect(screen.getByText(/3 new avatars unlocked/)).toBeInTheDocument() + + act(() => fireEvent.click(screen.getByRole('button', { name: /Choose avatar/ }))) + expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:BUG_WHISPERER') + expect(mockRouterPush).toHaveBeenCalledWith('/profile?avatarPicker=true') + expect(screen.queryByTestId('badge-detail-modal')).not.toBeInTheDocument() + }) + + it('says nothing about avatars for a badge that has none', () => { + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + render() + + render(mockToast.mock.calls[0][0].content) + expect(screen.queryByText(/avatar/i)).not.toBeInTheDocument() + }) + it('coalesces multiple badges and routes to /badges on tap', () => { mockPending = [badge('SHHHHH', 'Shhh'), badge('PRODUCT_HUNT', 'Product Hunt')] render() @@ -126,11 +147,10 @@ describe('BadgeEarnToast', () => { expect(mockToast).toHaveBeenCalledTimes(1) expect(mockMarkSeen).toHaveBeenCalledWith(['SHHHHH', 'PRODUCT_HUNT']) - const content = mockToast.mock.calls[0][0].content - render(content) + render(mockToast.mock.calls[0][0].content) expect(screen.getByText(/You unlocked 2 badges/)).toBeInTheDocument() - act(() => content.props.onClick()) + act(() => fireEvent.click(screen.getByRole('button', { name: /tap to view/ }))) expect(mockRouterPush).toHaveBeenCalledWith('/badges') expect(screen.queryByTestId('badge-detail-modal')).not.toBeInTheDocument() }) diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 5b546a0117..e7a00fe2f3 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -19,10 +19,14 @@ import { useResidenceRestrictions } from '@/hooks/useResidenceRestrictions' import InviteFriendsModal from '../Global/InviteFriendsModal' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import Image from 'next/image' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { AvatarPicker } from '@/components/Avatar/AvatarPicker' export const Profile = () => { const { logoutUser, isLoggingOut, user } = useAuth() const [isInviteFriendsModalOpen, setIsInviteFriendsModalOpen] = useState(false) + // URL state so the badge-earned toast can deep-link straight into the picker + const [avatarPickerOpen, setAvatarPickerOpen] = useQueryState('avatarPicker', parseAsBoolean) const router = useRouter() const onBack = useSafeBack('/home') // Profile "verified" reflects identity verification only (the human was ID-verified) — NOT @@ -53,7 +57,16 @@ export const Profile = () => {
- + setAvatarPickerOpen(true)} + /> + setAvatarPickerOpen(open ? true : null)} + />
{/* IA from #2834: identity/products first, then social + account, then app settings. Payment limits moved inline diff --git a/src/dev/fixtures/__tests__/fixtures.test.ts b/src/dev/fixtures/__tests__/fixtures.test.ts index 7c073e0652..57dde0fd19 100644 --- a/src/dev/fixtures/__tests__/fixtures.test.ts +++ b/src/dev/fixtures/__tests__/fixtures.test.ts @@ -9,7 +9,8 @@ const APP_DIR = join(process.cwd(), 'src', 'app', '(mobile-ui)') // A dynamic segment is a real route: /limits/manteca is served by limits/[provider]. function routeExists(route: string): boolean { let dir = APP_DIR - for (const segment of route.split('/').filter(Boolean)) { + // a fixture may open a route with its own query (nuqs URL state) + for (const segment of route.split('?')[0].split('/').filter(Boolean)) { if (existsSync(join(dir, segment))) { dir = join(dir, segment) continue diff --git a/src/dev/fixtures/registry.ts b/src/dev/fixtures/registry.ts index 1c68182085..188f0d54e6 100644 --- a/src/dev/fixtures/registry.ts +++ b/src/dev/fixtures/registry.ts @@ -333,6 +333,38 @@ export const FIXTURES: Record = { }, }, + // --------------------------------------------------------------------- + // Profile avatars (TASK-22142). + // --------------------------------------------------------------------- + 'home-avatar': { + route: '/home', + about: 'Home top nav wearing a picked basic avatar instead of the initial.', + responses: { 'GET /users/me': { user: { avatarKey: 'basic.frog' } } }, + }, + 'avatar-picker': { + route: '/profile?avatarPicker=true', + about: 'Avatar picker open: three Bug Whisperer avatars unlocked above the twenty basics, beetle selected.', + responses: { + 'GET /users/me': { + user: { + avatarKey: 'badge.BUG_WHISPERER.beetle', + badges: [ + { + id: 'demo-badge-bug-whisperer', + code: 'BUG_WHISPERER', + name: 'Bug Whisperer', + description: 'You found a real bug, reported it, and stayed. We owe you a beer.', + iconUrl: '/badges/bug_whisperer.svg', + color: null, + earnedAt: '2026-08-30T12:00:00.000Z', + isVisible: true, + }, + ], + }, + }, + }, + }, + // --------------------------------------------------------------------- // Error states. // --------------------------------------------------------------------- From 2df3f33c28671f14f36aa1c24d469b66331035db Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 17:13:09 +0100 Subject: [PATCH 04/17] fix(avatar): own avatar shows the first letter of the username, not the generated face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dot-face avatar (497ab2a5e) reads as a random emoji on the home chip and the profile header. Regress to the initials avatar in a first-letter mode for the user's own identity; contacts keep two-letter initials. The face is parked until avatar v2 — recover it from 497ab2a5e. Claude-Session: https://claude.ai/code/session_01BQuSkygKUt9jbs2N4iCKtk --- src/components/Profile/AvatarWithBadge.tsx | 14 +++++---- .../Profile/components/ProfileHeader.tsx | 29 +++---------------- src/components/UserHeader/index.tsx | 27 +++++++++++++++++ src/features/home/views/HomeTopNav.tsx | 25 +++++++++++----- .../home/views/__tests__/HomeTopNav.test.tsx | 20 +++---------- src/i18n/app/messages/en.json | 3 +- src/i18n/app/messages/en.marketing.json | 3 +- src/i18n/app/messages/es-419.json | 3 +- src/i18n/app/messages/es-419.marketing.json | 3 +- src/i18n/app/messages/es-AR.json | 3 +- src/i18n/app/messages/es-AR.marketing.json | 3 +- src/i18n/app/messages/pt-BR.json | 3 +- src/i18n/app/messages/pt-BR.marketing.json | 3 +- 13 files changed, 69 insertions(+), 70 deletions(-) diff --git a/src/components/Profile/AvatarWithBadge.tsx b/src/components/Profile/AvatarWithBadge.tsx index 5669aa9146..ef7759590c 100644 --- a/src/components/Profile/AvatarWithBadge.tsx +++ b/src/components/Profile/AvatarWithBadge.tsx @@ -19,6 +19,11 @@ interface AvatarWithBadgeProps { textColor?: string iconFillColor?: string logo?: string | StaticImageData + /** + * The user's own avatar (home chip, self profile header) shows the first + * letter of the username only; contacts keep two-letter initials. + */ + firstLetterOnly?: boolean /** * Rendered when `logo` fails to load (next/image onError). Lets a parent * provide a semantic fallback (e.g. bank tx → bank icon on dark bg) @@ -42,6 +47,7 @@ const AvatarWithBadge: React.FC = ({ iconFillColor, logo, fallback, + firstLetterOnly, }) => { const [logoFailed, setLogoFailed] = useState(false) // board 17802:61529 sizes XS/S/M/L are 24/32/48/64 — the boxes here already @@ -66,11 +72,9 @@ const AvatarWithBadge: React.FC = ({ } const initials = useMemo(() => { - if (name) { - return getInitialsFromName(name) - } - return '' - }, [name]) + if (!name) return '' + return firstLetterOnly ? name.trim().charAt(0).toUpperCase() : getInitialsFromName(name) + }, [name, firstLetterOnly]) if (logo && !logoFailed) { return ( diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index 06f0aae23d..a78a5f2e8f 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -6,9 +6,7 @@ import posthog from 'posthog-js' import React, { useEffect, useRef } from 'react' import { twMerge } from '@/utils/tw' import AvatarWithBadge from '../AvatarWithBadge' -import { UserAvatar } from '@/components/Avatar/UserAvatar' import { VerifiedUserLabel } from '@/components/UserHeader' -import { useTranslations } from 'next-intl' import { useAuth } from '@/context/authContext' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import CopyToClipboard from '@/components/Global/CopyToClipboard' @@ -22,8 +20,6 @@ interface ProfileHeaderProps { className?: string showShareButton?: boolean haveSentMoneyToUser?: boolean - /** Self profile only: makes the avatar a button that opens the picker. */ - onChangeAvatar?: () => void } const ProfileHeader: React.FC = ({ @@ -33,10 +29,8 @@ const ProfileHeader: React.FC = ({ className, showShareButton = true, haveSentMoneyToUser = false, - onChangeAvatar, }) => { const { user: authenticatedUser } = useAuth() - const tAvatar = useTranslations('avatar') // The self-profile verified badge means "this person's ID was confirmed" — // NOT "this person has an enabled payment rail." It reads identityVerification // (Sumsub-cleared), matching the counterparty badge logic (`isVerified` on @@ -68,26 +62,11 @@ const ProfileHeader: React.FC = ({ return ( <>
- {/* Own profile wears the picked avatar (or one username initial) - at Avatar L; someone else's public profile keeps initials - (letters identify others). */} + {/* Own profile shows the first letter of the username; someone + else's public profile keeps initials (letters identify others). + The generated face (497ab2a5e) is parked until avatar v2. */} {isSelfProfile ? ( - onChangeAvatar ? ( - - ) : ( - - ) + ) : ( )} diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index d927512697..bef561d054 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -1,13 +1,40 @@ 'use client' +import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' +import Link from 'next/link' import { Icon } from '../Global/Icons/Icon' import { twMerge } from '@/utils/tw' import { Tooltip } from '../Tooltip' import { useMemo } from 'react' import { useAuth } from '@/context/authContext' import AddressLink from '../Global/AddressLink' +import { Button } from '@/components/0_Bruddle/Button' import { isCryptoAddress } from '@/utils/general.utils' +interface UserHeaderProps { + username: string + fullName?: string + isVerified?: boolean +} + +export const UserHeader = ({ username }: UserHeaderProps) => { + return ( + + + + ) +} + /** * Default type for the name. It has to be a type TOKEN, not a bare weight * class: a caller passing `text-heading-s` (ProfileHeader does) merges with a diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx index d6dd96df62..500d47463a 100644 --- a/src/features/home/views/HomeTopNav.tsx +++ b/src/features/home/views/HomeTopNav.tsx @@ -1,15 +1,14 @@ 'use client' -import { UserAvatar } from '@/components/Avatar/UserAvatar' import { Icon } from '@/components/Global/Icons/Icon' import InvitesIcon from '@/components/Home/InvitesIcon' +import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import { useAppHaptic } from '@/hooks/useAppHaptic' import { useAppTranslations } from '@/i18n/app/useAppTranslations' import Link from 'next/link' interface HomeTopNavProps { - username?: string - avatarKey?: string | null + avatarName?: string showRewards: boolean } @@ -18,7 +17,7 @@ interface HomeTopNavProps { * top-left linking to /profile (Vlad follow-up: one size down from 48), * rewards link top-right. The link keeps a 44px hit area via after: inset. */ -export function HomeTopNav({ username, avatarKey, showRewards }: HomeTopNavProps) { +export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) { const t = useAppTranslations('home') const { triggerHaptic } = useAppHaptic() @@ -31,10 +30,20 @@ export function HomeTopNav({ username, avatarKey, showRewards }: HomeTopNavProps className="relative block after:absolute after:-inset-1.5" aria-label={t('openProfile')} > - {/* The user's picked avatar (TASK-22142), or the privacy-safe - fallback: one username initial on its color. Never the full - name — that is verification data. */} - + {/* Own identity: the first letter of the username, here and on + the profile header (the generated face is parked until avatar + v2). A user with no name string at all still gets an + avatar-toned circle (yellow — the palette's no-name default). */} + {avatarName ? ( + + ) : ( + + )} {showRewards && ( ({ useAppHaptic: () => ({ triggerHaptic: jest.fn() }) })) jest.mock('@/components/Home/InvitesIcon', () => ({ __esModule: true, default: () => null })) -jest.mock('next/image', () => ({ - __esModule: true, - default: ({ unoptimized, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean }) => , -})) describe('HomeTopNav', () => { - it('wears the picked avatar inside the profile link', () => { - const { container } = renderWithIntl( - - ) - - expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/basic/frog.svg') - }) - - it('falls back to one username initial, not the name initials', () => { - const { container } = renderWithIntl() + it('shows the first letter of the username — not two-letter initials, not a generated face', () => { + const { container } = renderWithIntl() expect(container.querySelector('a[href="/profile"]')).toHaveTextContent(/^T$/) expect(screen.queryByText(/^TE$/i)).not.toBeInTheDocument() + expect(container.querySelector('a[href="/profile"] svg')).not.toBeInTheDocument() }) - it('still links to the profile when there is no username yet', () => { + it('falls back to the no-name circle when there is no username yet', () => { const { container } = renderWithIntl() expect(container.querySelector('a[href="/profile"]')).toBeInTheDocument() diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 0a80de14b6..edf58b71df 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -55,8 +55,7 @@ "unknown": "Unknown" }, "exchangeRate": "Exchange rate", - "slideToProceed": "Slide to Proceed", - "userAvatarAlt": "Avatar for {username}" + "slideToProceed": "Slide to Proceed" }, "navigation": { "home": "Home", diff --git a/src/i18n/app/messages/en.marketing.json b/src/i18n/app/messages/en.marketing.json index 0bf810b264..8b682b636e 100644 --- a/src/i18n/app/messages/en.marketing.json +++ b/src/i18n/app/messages/en.marketing.json @@ -55,8 +55,7 @@ "unknown": "Unknown" }, "exchangeRate": "Exchange rate", - "slideToProceed": "Slide to Proceed", - "userAvatarAlt": "Avatar for {username}" + "slideToProceed": "Slide to Proceed" }, "errors": { "balanceSettling": "Your balance isn't fully available yet. Please try again in a few seconds.", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index a36ab0fa05..cb7b934274 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -55,8 +55,7 @@ "unknown": "Desconocido" }, "exchangeRate": "Tipo de cambio", - "slideToProceed": "Desliza para continuar", - "userAvatarAlt": "Avatar de {username}" + "slideToProceed": "Desliza para continuar" }, "navigation": { "home": "Inicio", diff --git a/src/i18n/app/messages/es-419.marketing.json b/src/i18n/app/messages/es-419.marketing.json index 9f93c26f7e..f8f1db8694 100644 --- a/src/i18n/app/messages/es-419.marketing.json +++ b/src/i18n/app/messages/es-419.marketing.json @@ -55,8 +55,7 @@ "unknown": "Desconocido" }, "exchangeRate": "Tipo de cambio", - "slideToProceed": "Desliza para continuar", - "userAvatarAlt": "Avatar de {username}" + "slideToProceed": "Desliza para continuar" }, "errors": { "balanceSettling": "Tu saldo aún no está totalmente disponible. Inténtalo de nuevo en unos segundos.", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index a0ee93ef28..2e3f142453 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -2,8 +2,7 @@ "common": { "genericError": "Algo salió mal. Intentá de nuevo o contactá soporte.", "tryDifferentCountry": "Probá con otro país.", - "slideToProceed": "Deslizá para continuar", - "userAvatarAlt": "Avatar de {username}" + "slideToProceed": "Deslizá para continuar" }, "home": { "activation": { diff --git a/src/i18n/app/messages/es-AR.marketing.json b/src/i18n/app/messages/es-AR.marketing.json index 484858a2c8..44b0f0885a 100644 --- a/src/i18n/app/messages/es-AR.marketing.json +++ b/src/i18n/app/messages/es-AR.marketing.json @@ -2,8 +2,7 @@ "common": { "genericError": "Algo salió mal. Intentá de nuevo o contactá soporte.", "tryDifferentCountry": "Probá con otro país.", - "slideToProceed": "Deslizá para continuar", - "userAvatarAlt": "Avatar de {username}" + "slideToProceed": "Deslizá para continuar" }, "errors": { "balanceSettling": "Tu saldo aún no está totalmente disponible. Intentalo de nuevo en unos segundos.", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index f0d342fe40..e1a1d504c7 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -55,8 +55,7 @@ "unknown": "Desconhecido" }, "exchangeRate": "Taxa de câmbio", - "slideToProceed": "Deslize para continuar", - "userAvatarAlt": "Avatar de {username}" + "slideToProceed": "Deslize para continuar" }, "navigation": { "home": "Início", diff --git a/src/i18n/app/messages/pt-BR.marketing.json b/src/i18n/app/messages/pt-BR.marketing.json index 2527512fbd..457c76fdce 100644 --- a/src/i18n/app/messages/pt-BR.marketing.json +++ b/src/i18n/app/messages/pt-BR.marketing.json @@ -55,8 +55,7 @@ "unknown": "Desconhecido" }, "exchangeRate": "Taxa de câmbio", - "slideToProceed": "Deslize para continuar", - "userAvatarAlt": "Avatar de {username}" + "slideToProceed": "Deslize para continuar" }, "errors": { "balanceSettling": "Seu saldo ainda não está totalmente disponível. Tente novamente em alguns segundos.", From a08058bb1d3586f2ac32cf78e008a09ad4b74d97 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 22:54:09 +0100 Subject: [PATCH 05/17] feat(avatars): stack the picker on the first-letter fallback (TASK-22142) The one-letter own-avatar now comes from AvatarWithBadge's firstLetterOnly mode (#2924, cherry-picked underneath). UserAvatar only adds the picked-key branch and otherwise renders that same fallback, so the fallback lives in one place. HomeTopNav and the self ProfileHeader pass the pick through. --- src/components/Avatar/AvatarPicker.tsx | 2 +- src/components/Avatar/UserAvatar.tsx | 71 +++++++++---------- .../Avatar/__tests__/UserAvatar.test.tsx | 34 +++++---- .../Profile/components/ProfileHeader.tsx | 19 ++++- src/features/home/HomePage.tsx | 3 +- .../home/__tests__/useHomeFlow.test.ts | 21 +++++- src/features/home/useHomeFlow.ts | 8 ++- src/features/home/views/HomeTopNav.tsx | 24 +++---- .../home/views/__tests__/HomeTopNav.test.tsx | 14 ++++ 9 files changed, 116 insertions(+), 80 deletions(-) diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index e372999f92..0ff5d0cca1 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -91,7 +91,7 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { checked && 'border-2 border-border-default shadow-[3px_3px_0_var(--color-shadow-primary)]' )} > - + ) })} diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx index 7914e069ac..a2948235f9 100644 --- a/src/components/Avatar/UserAvatar.tsx +++ b/src/components/Avatar/UserAvatar.tsx @@ -1,70 +1,65 @@ 'use client' import Image from 'next/image' -import { useTranslations } from 'next-intl' -import { Icon } from '@/components/Global/Icons/Icon' -import type { AvatarSize } from '@/components/Profile/AvatarWithBadge' -import { getColorForUsername } from '@/utils/color.utils' +import AvatarWithBadge, { type AvatarSize } from '@/components/Profile/AvatarWithBadge' import { twMerge } from '@/utils/tw' import { avatarPalette, avatarSrc } from './avatar.utils' interface UserAvatarProps { - /** The username only. The fallback is its first character — never the - * full name or anything from verification, which would leak PII. */ - username?: string + /** Display name for the fallback; AvatarWithBadge shows its first letter. */ + name?: string avatarKey?: string | null size?: AvatarSize className?: string } // board 17802:61529: XS 24 · S 32 · M 48 · L 64 (+ the code-only 96), circle, -// 1px border. Same boxes and type steps as AvatarWithBadge. +// 1px border — the same boxes AvatarWithBadge draws. const SIZE_CLASSES: Record = { - tiny: 'size-6 text-label-m', - 'extra-small': 'size-8 text-label-m', - small: 'size-12 text-body-m-semibold', - medium: 'size-16 text-heading-s', - large: 'size-24 text-heading-m', + tiny: 'size-6', + 'extra-small': 'size-8', + small: 'size-12', + medium: 'size-16', + large: 'size-24', } const SIZE_PX: Record = { tiny: 24, 'extra-small': 32, small: 48, medium: 64, large: 96 } -const ICON_PX: Record = { tiny: 12, 'extra-small': 16, small: 18, medium: 32, large: 48 } /** - * The user's own avatar: their picked character on its palette triple, or - * the privacy-safe fallback — one username initial on the username's color. + * The user's own avatar (TASK-22142): the picked character on its palette + * triple. Without a pick — or with a key the manifest does not know — it is + * exactly the existing first-letter avatar, so the fallback lives in one place. */ -export function UserAvatar({ username, avatarKey, size = 'extra-small', className }: UserAvatarProps) { - const t = useTranslations('common') +export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: UserAvatarProps) { const src = avatarSrc(avatarKey) - const initial = username?.trim().charAt(0).toUpperCase() || '' - // the seven triples travel together (fill, border, foreground); no-username - // resolves to the palette's yellow default, like the rest of the app - const palette = src && avatarKey ? avatarPalette(avatarKey) : getColorForUsername(initial ? username : undefined) + if (!src || !avatarKey) { + return name ? ( + + ) : ( + + ) + } + // the seven triples travel together (fill, border, foreground) + const palette = avatarPalette(avatarKey) return ( - {src ? ( - - ) : initial ? ( - initial - ) : ( - - )} + ) } diff --git a/src/components/Avatar/__tests__/UserAvatar.test.tsx b/src/components/Avatar/__tests__/UserAvatar.test.tsx index a57a7dfbf2..93732e79f3 100644 --- a/src/components/Avatar/__tests__/UserAvatar.test.tsx +++ b/src/components/Avatar/__tests__/UserAvatar.test.tsx @@ -1,6 +1,5 @@ -import { screen } from '@testing-library/react' +import { render } from '@testing-library/react' import type { ComponentProps } from 'react' -import { renderWithIntl } from '@/test-utils/intl' import { UserAvatar } from '../UserAvatar' jest.mock('next/image', () => ({ @@ -10,33 +9,32 @@ jest.mock('next/image', () => ({ describe('UserAvatar', () => { it('renders the picked character on its palette', () => { - renderWithIntl() + const { container } = render() - expect(screen.getByRole('img', { name: 'Avatar for satoshi' })).toBeInTheDocument() - expect(document.querySelector('img')).toHaveAttribute('src', '/avatars/basic/frog.svg') - expect(screen.queryByText('S')).not.toBeInTheDocument() + expect(container.querySelector('img')).toHaveAttribute('src', '/avatars/basic/frog.svg') + expect(container).not.toHaveTextContent('S') }) - // privacy-safe fallback: exactly one character of the USERNAME. The - // component has no name/fullName prop at all, so verification data cannot - // reach it — a username that looks like a full name still yields one letter. - it('falls back to a single username initial, never more', () => { - renderWithIntl() + // no pick → the existing first-letter avatar, so the fallback lives in + // one place (AvatarWithBadge firstLetterOnly, #2924) + it('falls back to the first-letter avatar without a pick', () => { + const { container } = render() - expect(screen.getByRole('img', { name: 'Avatar for Satoshi Nakamoto' })).toHaveTextContent(/^S$/) - expect(document.querySelector('img')).toBeNull() + expect(container.querySelector('img')).toBeNull() + expect(container).toHaveTextContent(/^S$/) }) it('treats a key the manifest does not know as no pick', () => { - renderWithIntl() + const { container } = render() - expect(screen.getByRole('img', { name: 'Avatar for hal' })).toHaveTextContent(/^H$/) + expect(container.querySelector('img')).toBeNull() + expect(container).toHaveTextContent(/^H$/) }) - it('shows the generic user glyph with no username at all', () => { - const { container } = renderWithIntl() + it('shows the generic user glyph with no name at all', () => { + const { container } = render() - expect(screen.queryByRole('img')).not.toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() + expect(container).toHaveTextContent('') }) }) diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index a78a5f2e8f..8c43ae1407 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -6,6 +6,8 @@ import posthog from 'posthog-js' import React, { useEffect, useRef } from 'react' import { twMerge } from '@/utils/tw' import AvatarWithBadge from '../AvatarWithBadge' +import { UserAvatar } from '@/components/Avatar/UserAvatar' +import { useTranslations } from 'next-intl' import { VerifiedUserLabel } from '@/components/UserHeader' import { useAuth } from '@/context/authContext' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -20,6 +22,8 @@ interface ProfileHeaderProps { className?: string showShareButton?: boolean haveSentMoneyToUser?: boolean + /** Self profile only: makes the avatar a button that opens the picker (TASK-22142). */ + onChangeAvatar?: () => void } const ProfileHeader: React.FC = ({ @@ -29,8 +33,10 @@ const ProfileHeader: React.FC = ({ className, showShareButton = true, haveSentMoneyToUser = false, + onChangeAvatar, }) => { const { user: authenticatedUser } = useAuth() + const tAvatar = useTranslations('avatar') // The self-profile verified badge means "this person's ID was confirmed" — // NOT "this person has an enabled payment rail." It reads identityVerification // (Sumsub-cleared), matching the counterparty badge logic (`isVerified` on @@ -66,7 +72,18 @@ const ProfileHeader: React.FC = ({ else's public profile keeps initials (letters identify others). The generated face (497ab2a5e) is parked until avatar v2. */} {isSelfProfile ? ( - + onChangeAvatar ? ( + + ) : ( + + ) ) : ( )} diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx index 6d13e74ab6..92df33a450 100644 --- a/src/features/home/HomePage.tsx +++ b/src/features/home/HomePage.tsx @@ -27,6 +27,7 @@ export function HomePage() { const { isPageLoading, username, + avatarName, avatarKey, isActivated, activationStep, @@ -45,7 +46,7 @@ export function HomePage() { return (
- + { expect(mockDisconnect).not.toHaveBeenCalled() }) - it('passes the picked avatar through and never derives one from the full name', () => { - mockUser = userWith({ avatarKey: 'basic.frog', showFullName: true, fullName: 'Kushagra S' }) + it('derives avatarName from the showFullName preference', () => { + mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' }) + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') + + mockUser = userWith({ showFullName: false, fullName: 'Kushagra S' }) + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('kush') + + // usernameless: full name still seeds the initials + mockUser = { user: { userId: 'u1', username: null, fullName: 'Kushagra S' } } + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') + + mockUser = { user: { userId: 'u1', username: null } } + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBeUndefined() + }) + + it('passes the picked avatar through, null when there is none', () => { + mockUser = userWith({ avatarKey: 'basic.frog' }) expect(renderHook(() => useHomeFlow()).result.current.avatarKey).toBe('basic.frog') - mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' }) + mockUser = userWith({}) expect(renderHook(() => useHomeFlow()).result.current.avatarKey).toBeNull() }) }) diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts index e926096988..4f30dce163 100644 --- a/src/features/home/useHomeFlow.ts +++ b/src/features/home/useHomeFlow.ts @@ -51,13 +51,17 @@ export function useHomeFlow() { } }, [isWagmiConnected, disconnectWagmi]) - // the picked avatar (TASK-22142); the top nav falls back to one username - // initial, never to the full name, so nothing else is derived here + // respect the showFullName preference for the avatar initials; a + // usernameless user still gets initials from their full name (initials + // only — the preference governs showing the full name, not its initials) + const avatarName = (user?.user.showFullName && user?.user.fullName) || username || user?.user.fullName || undefined + // the picked avatar (TASK-22142); null keeps the first-letter fallback const avatarKey = user?.user.avatarKey ?? null return { isPageLoading: isFetchingUser && !username, username, + avatarName, avatarKey, isActivated, activationStep, diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx index 500d47463a..63c1241709 100644 --- a/src/features/home/views/HomeTopNav.tsx +++ b/src/features/home/views/HomeTopNav.tsx @@ -1,14 +1,15 @@ 'use client' +import { UserAvatar } from '@/components/Avatar/UserAvatar' import { Icon } from '@/components/Global/Icons/Icon' import InvitesIcon from '@/components/Home/InvitesIcon' -import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import { useAppHaptic } from '@/hooks/useAppHaptic' import { useAppTranslations } from '@/i18n/app/useAppTranslations' import Link from 'next/link' interface HomeTopNavProps { avatarName?: string + avatarKey?: string | null showRewards: boolean } @@ -17,7 +18,7 @@ interface HomeTopNavProps { * top-left linking to /profile (Vlad follow-up: one size down from 48), * rewards link top-right. The link keeps a 44px hit area via after: inset. */ -export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) { +export function HomeTopNav({ avatarName, avatarKey, showRewards }: HomeTopNavProps) { const t = useAppTranslations('home') const { triggerHaptic } = useAppHaptic() @@ -30,20 +31,11 @@ export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) { className="relative block after:absolute after:-inset-1.5" aria-label={t('openProfile')} > - {/* Own identity: the first letter of the username, here and on - the profile header (the generated face is parked until avatar - v2). A user with no name string at all still gets an - avatar-toned circle (yellow — the palette's no-name default). */} - {avatarName ? ( - - ) : ( - - )} + {/* Own identity: the picked avatar (TASK-22142), or the first + letter of the name, here and on the profile header. A user + with no name string at all still gets an avatar-toned circle + (yellow — the palette's no-name default). */} + {showRewards && ( ({ useAppHaptic: () => ({ triggerHaptic: jest.fn() }) })) jest.mock('@/components/Home/InvitesIcon', () => ({ __esModule: true, default: () => null })) +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean }) => , +})) describe('HomeTopNav', () => { it('shows the first letter of the username — not two-letter initials, not a generated face', () => { @@ -14,6 +19,15 @@ describe('HomeTopNav', () => { expect(container.querySelector('a[href="/profile"] svg')).not.toBeInTheDocument() }) + it('wears the picked avatar inside the profile link (TASK-22142)', () => { + const { container } = renderWithIntl( + + ) + + expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/basic/frog.svg') + expect(container.querySelector('a[href="/profile"]')).not.toHaveTextContent('T') + }) + it('falls back to the no-name circle when there is no username yet', () => { const { container } = renderWithIntl() From 61ffd419f4ee08108fd4b97fee059d1720c3c2cb Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 22:56:15 +0100 Subject: [PATCH 06/17] fix(avatars): palette by class, not inline style (ds-lint ratchet) The picked-avatar circle used an inline style for its triple; the ratchet counts inline styles and only lets them go down. Emit the seven avatar triples as full class literals instead, like the rest of the app. --- src/components/Avatar/UserAvatar.tsx | 6 ++---- .../Avatar/__tests__/avatar.utils.test.ts | 10 +++++---- src/components/Avatar/avatar.utils.ts | 21 ++++++++++++++++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx index a2948235f9..4875c98f1c 100644 --- a/src/components/Avatar/UserAvatar.tsx +++ b/src/components/Avatar/UserAvatar.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import AvatarWithBadge, { type AvatarSize } from '@/components/Profile/AvatarWithBadge' import { twMerge } from '@/utils/tw' -import { avatarPalette, avatarSrc } from './avatar.utils' +import { avatarPaletteClass, avatarSrc } from './avatar.utils' interface UserAvatarProps { /** Display name for the fallback; AvatarWithBadge shows its first letter. */ @@ -47,17 +47,15 @@ export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: ) } - // the seven triples travel together (fill, border, foreground) - const palette = avatarPalette(avatarKey) return ( diff --git a/src/components/Avatar/__tests__/avatar.utils.test.ts b/src/components/Avatar/__tests__/avatar.utils.test.ts index ff21de3e51..1a3ad86cae 100644 --- a/src/components/Avatar/__tests__/avatar.utils.test.ts +++ b/src/components/Avatar/__tests__/avatar.utils.test.ts @@ -1,7 +1,7 @@ import { existsSync } from 'fs' import { join } from 'path' import badgeAssets from '@/types/badge-assets.json' -import { avatarPalette, avatarPool, avatarSrc, badgeAvatarKeys, basicAvatarKeys } from '../avatar.utils' +import { avatarPaletteClass, avatarPool, avatarSrc, badgeAvatarKeys, basicAvatarKeys } from '../avatar.utils' describe('avatar catalog', () => { // the manifest is the API's contract: every slug it names must be real art @@ -43,8 +43,10 @@ describe('avatar catalog', () => { expect(avatarSrc(undefined)).toBeNull() }) - it('keeps a stable palette per key', () => { - expect(avatarPalette('basic.apple')).toEqual(avatarPalette('basic.apple')) - expect(avatarPalette('basic.apple')).toHaveProperty('lightShade') + it('keeps a stable palette per key, from the seven avatar triples', () => { + expect(avatarPaletteClass('basic.apple')).toBe(avatarPaletteClass('basic.apple')) + expect(avatarPaletteClass('basic.apple')).toMatch( + /^bg-avatar-(pink|yellow|purple|blue|red|orange|green) border-avatar-\1-border$/ + ) }) }) diff --git a/src/components/Avatar/avatar.utils.ts b/src/components/Avatar/avatar.utils.ts index 4a8b934ac9..71c21d7e06 100644 --- a/src/components/Avatar/avatar.utils.ts +++ b/src/components/Avatar/avatar.utils.ts @@ -9,7 +9,6 @@ * it only mirrors the manifest into paths and palettes. */ import badgeAssets from '@/types/badge-assets.json' -import { getColorForUsername } from '@/utils/color.utils' const BASICS: readonly string[] = badgeAssets.avatars.basics const BADGE_AVATARS: Readonly> = badgeAssets.avatars.badges @@ -37,5 +36,21 @@ export function avatarSrc(key: string | null | undefined): string | null { return null } -/** One of the seven avatar triples (board 17802:61529), stable per key. */ -export const avatarPalette = (key: string) => getColorForUsername(key) +// the seven avatar triples (board 17802:61529) as full class literals so the +// tailwind scanner emits them; fill and border travel together +const PALETTE_CLASSES = [ + 'bg-avatar-pink border-avatar-pink-border', + 'bg-avatar-yellow border-avatar-yellow-border', + 'bg-avatar-purple border-avatar-purple-border', + 'bg-avatar-blue border-avatar-blue-border', + 'bg-avatar-red border-avatar-red-border', + 'bg-avatar-orange border-avatar-orange-border', + 'bg-avatar-green border-avatar-green-border', +] as const + +/** Fill + border classes of one avatar triple, stable per key. */ +export function avatarPaletteClass(key: string): string { + let hash = 5381 + for (let i = 0; i < key.length; i++) hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0 + return PALETTE_CLASSES[hash % PALETTE_CLASSES.length] +} From 5b27877336f40e350c9348a3cd64a9868a59b7b0 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 23:07:46 +0100 Subject: [PATCH 07/17] feat(avatars): art for the fifteen badges with more than 20 holders Three avatars per badge (prod holder counts, 2026-09-02), drawn for the circle in the house style; SUPPORT_SURVIVOR reuses the Bug Whisperer set. Manifest regenerated from the API catalog. --- .../ARBIVERSE_DEVCONNECT_BA_2025/cheer.svg | 1 + .../ARBIVERSE_DEVCONNECT_BA_2025/crystal.svg | 1 + .../badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.svg | 1 + public/avatars/badge/BETA_TESTER/bubble.svg | 1 + public/avatars/badge/BETA_TESTER/flask.svg | 1 + public/avatars/badge/BETA_TESTER/wink.svg | 1 + public/avatars/badge/CARD_ALPHA/card.svg | 1 + public/avatars/badge/CARD_ALPHA/swipe.svg | 1 + public/avatars/badge/CARD_ALPHA/tape.svg | 1 + public/avatars/badge/CARD_FIRST_SWIPE/card.svg | 1 + public/avatars/badge/CARD_FIRST_SWIPE/chip.svg | 1 + public/avatars/badge/CARD_FIRST_SWIPE/wink.svg | 1 + public/avatars/badge/CARD_SPENT_1K/cheer.svg | 1 + public/avatars/badge/CARD_SPENT_1K/note.svg | 1 + public/avatars/badge/CARD_SPENT_1K/stack.svg | 1 + .../avatars/badge/DEVCONNECT_BA_2025/rise.svg | 1 + public/avatars/badge/DEVCONNECT_BA_2025/sun.svg | 1 + .../avatars/badge/DEVCONNECT_BA_2025/wink.svg | 1 + public/avatars/badge/ETHFLORIPA_HUB/eth.svg | 1 + public/avatars/badge/ETHFLORIPA_HUB/island.svg | 1 + public/avatars/badge/ETHFLORIPA_HUB/palm.svg | 1 + public/avatars/badge/EVENT_ALUMNI/board.svg | 1 + public/avatars/badge/EVENT_ALUMNI/cap.svg | 1 + public/avatars/badge/EVENT_ALUMNI/wink.svg | 1 + public/avatars/badge/IRL_NOMADS/pack.svg | 1 + public/avatars/badge/IRL_NOMADS/pocket.svg | 1 + public/avatars/badge/IRL_NOMADS/roll.svg | 1 + public/avatars/badge/NAIJA/flag.svg | 1 + public/avatars/badge/NAIJA/wave.svg | 1 + public/avatars/badge/NAIJA/wink.svg | 1 + public/avatars/badge/NITA/letter.svg | 1 + public/avatars/badge/NITA/sparkle.svg | 1 + public/avatars/badge/NITA/wink.svg | 1 + public/avatars/badge/OG_2025_10_12/coin.svg | 1 + public/avatars/badge/OG_2025_10_12/link.svg | 1 + public/avatars/badge/OG_2025_10_12/shades.svg | 1 + public/avatars/badge/SHHHHH/lips.svg | 1 + public/avatars/badge/SHHHHH/shush.svg | 1 + public/avatars/badge/SHHHHH/wink.svg | 1 + .../avatars/badge/SUPPORT_SURVIVOR/beetle.svg | 1 + public/avatars/badge/SUPPORT_SURVIVOR/peek.svg | 1 + public/avatars/badge/SUPPORT_SURVIVOR/shell.svg | 1 + public/avatars/badge/WAITLIST_SKIP/key.svg | 1 + public/avatars/badge/WAITLIST_SKIP/keyhole.svg | 1 + public/avatars/badge/WAITLIST_SKIP/wink.svg | 1 + src/types/badge-assets.json | 17 ++++++++++++++++- 46 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.svg create mode 100644 public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.svg create mode 100644 public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.svg create mode 100644 public/avatars/badge/BETA_TESTER/bubble.svg create mode 100644 public/avatars/badge/BETA_TESTER/flask.svg create mode 100644 public/avatars/badge/BETA_TESTER/wink.svg create mode 100644 public/avatars/badge/CARD_ALPHA/card.svg create mode 100644 public/avatars/badge/CARD_ALPHA/swipe.svg create mode 100644 public/avatars/badge/CARD_ALPHA/tape.svg create mode 100644 public/avatars/badge/CARD_FIRST_SWIPE/card.svg create mode 100644 public/avatars/badge/CARD_FIRST_SWIPE/chip.svg create mode 100644 public/avatars/badge/CARD_FIRST_SWIPE/wink.svg create mode 100644 public/avatars/badge/CARD_SPENT_1K/cheer.svg create mode 100644 public/avatars/badge/CARD_SPENT_1K/note.svg create mode 100644 public/avatars/badge/CARD_SPENT_1K/stack.svg create mode 100644 public/avatars/badge/DEVCONNECT_BA_2025/rise.svg create mode 100644 public/avatars/badge/DEVCONNECT_BA_2025/sun.svg create mode 100644 public/avatars/badge/DEVCONNECT_BA_2025/wink.svg create mode 100644 public/avatars/badge/ETHFLORIPA_HUB/eth.svg create mode 100644 public/avatars/badge/ETHFLORIPA_HUB/island.svg create mode 100644 public/avatars/badge/ETHFLORIPA_HUB/palm.svg create mode 100644 public/avatars/badge/EVENT_ALUMNI/board.svg create mode 100644 public/avatars/badge/EVENT_ALUMNI/cap.svg create mode 100644 public/avatars/badge/EVENT_ALUMNI/wink.svg create mode 100644 public/avatars/badge/IRL_NOMADS/pack.svg create mode 100644 public/avatars/badge/IRL_NOMADS/pocket.svg create mode 100644 public/avatars/badge/IRL_NOMADS/roll.svg create mode 100644 public/avatars/badge/NAIJA/flag.svg create mode 100644 public/avatars/badge/NAIJA/wave.svg create mode 100644 public/avatars/badge/NAIJA/wink.svg create mode 100644 public/avatars/badge/NITA/letter.svg create mode 100644 public/avatars/badge/NITA/sparkle.svg create mode 100644 public/avatars/badge/NITA/wink.svg create mode 100644 public/avatars/badge/OG_2025_10_12/coin.svg create mode 100644 public/avatars/badge/OG_2025_10_12/link.svg create mode 100644 public/avatars/badge/OG_2025_10_12/shades.svg create mode 100644 public/avatars/badge/SHHHHH/lips.svg create mode 100644 public/avatars/badge/SHHHHH/shush.svg create mode 100644 public/avatars/badge/SHHHHH/wink.svg create mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg create mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/peek.svg create mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/shell.svg create mode 100644 public/avatars/badge/WAITLIST_SKIP/key.svg create mode 100644 public/avatars/badge/WAITLIST_SKIP/keyhole.svg create mode 100644 public/avatars/badge/WAITLIST_SKIP/wink.svg diff --git a/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.svg b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.svg new file mode 100644 index 0000000000..9ad6e3f8d7 --- /dev/null +++ b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.svg b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.svg new file mode 100644 index 0000000000..06c51d9e65 --- /dev/null +++ b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.svg b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.svg new file mode 100644 index 0000000000..a6fd2eabe3 --- /dev/null +++ b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/BETA_TESTER/bubble.svg b/public/avatars/badge/BETA_TESTER/bubble.svg new file mode 100644 index 0000000000..d647238751 --- /dev/null +++ b/public/avatars/badge/BETA_TESTER/bubble.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/BETA_TESTER/flask.svg b/public/avatars/badge/BETA_TESTER/flask.svg new file mode 100644 index 0000000000..14c8525df9 --- /dev/null +++ b/public/avatars/badge/BETA_TESTER/flask.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/BETA_TESTER/wink.svg b/public/avatars/badge/BETA_TESTER/wink.svg new file mode 100644 index 0000000000..1fecd746b4 --- /dev/null +++ b/public/avatars/badge/BETA_TESTER/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_ALPHA/card.svg b/public/avatars/badge/CARD_ALPHA/card.svg new file mode 100644 index 0000000000..75bb630cca --- /dev/null +++ b/public/avatars/badge/CARD_ALPHA/card.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_ALPHA/swipe.svg b/public/avatars/badge/CARD_ALPHA/swipe.svg new file mode 100644 index 0000000000..2ebb907c0e --- /dev/null +++ b/public/avatars/badge/CARD_ALPHA/swipe.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_ALPHA/tape.svg b/public/avatars/badge/CARD_ALPHA/tape.svg new file mode 100644 index 0000000000..5dc958f8b1 --- /dev/null +++ b/public/avatars/badge/CARD_ALPHA/tape.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_FIRST_SWIPE/card.svg b/public/avatars/badge/CARD_FIRST_SWIPE/card.svg new file mode 100644 index 0000000000..f05e729b06 --- /dev/null +++ b/public/avatars/badge/CARD_FIRST_SWIPE/card.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_FIRST_SWIPE/chip.svg b/public/avatars/badge/CARD_FIRST_SWIPE/chip.svg new file mode 100644 index 0000000000..0a9e949dfb --- /dev/null +++ b/public/avatars/badge/CARD_FIRST_SWIPE/chip.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_FIRST_SWIPE/wink.svg b/public/avatars/badge/CARD_FIRST_SWIPE/wink.svg new file mode 100644 index 0000000000..67a015ba7c --- /dev/null +++ b/public/avatars/badge/CARD_FIRST_SWIPE/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_SPENT_1K/cheer.svg b/public/avatars/badge/CARD_SPENT_1K/cheer.svg new file mode 100644 index 0000000000..ade2bb2b09 --- /dev/null +++ b/public/avatars/badge/CARD_SPENT_1K/cheer.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_SPENT_1K/note.svg b/public/avatars/badge/CARD_SPENT_1K/note.svg new file mode 100644 index 0000000000..31bb2d6150 --- /dev/null +++ b/public/avatars/badge/CARD_SPENT_1K/note.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/CARD_SPENT_1K/stack.svg b/public/avatars/badge/CARD_SPENT_1K/stack.svg new file mode 100644 index 0000000000..ed16dafede --- /dev/null +++ b/public/avatars/badge/CARD_SPENT_1K/stack.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/DEVCONNECT_BA_2025/rise.svg b/public/avatars/badge/DEVCONNECT_BA_2025/rise.svg new file mode 100644 index 0000000000..0a21306dfc --- /dev/null +++ b/public/avatars/badge/DEVCONNECT_BA_2025/rise.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/DEVCONNECT_BA_2025/sun.svg b/public/avatars/badge/DEVCONNECT_BA_2025/sun.svg new file mode 100644 index 0000000000..25b03f98f1 --- /dev/null +++ b/public/avatars/badge/DEVCONNECT_BA_2025/sun.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/DEVCONNECT_BA_2025/wink.svg b/public/avatars/badge/DEVCONNECT_BA_2025/wink.svg new file mode 100644 index 0000000000..b599113f12 --- /dev/null +++ b/public/avatars/badge/DEVCONNECT_BA_2025/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/ETHFLORIPA_HUB/eth.svg b/public/avatars/badge/ETHFLORIPA_HUB/eth.svg new file mode 100644 index 0000000000..063c975871 --- /dev/null +++ b/public/avatars/badge/ETHFLORIPA_HUB/eth.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/ETHFLORIPA_HUB/island.svg b/public/avatars/badge/ETHFLORIPA_HUB/island.svg new file mode 100644 index 0000000000..eff2287db1 --- /dev/null +++ b/public/avatars/badge/ETHFLORIPA_HUB/island.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/ETHFLORIPA_HUB/palm.svg b/public/avatars/badge/ETHFLORIPA_HUB/palm.svg new file mode 100644 index 0000000000..a43f695ee2 --- /dev/null +++ b/public/avatars/badge/ETHFLORIPA_HUB/palm.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/EVENT_ALUMNI/board.svg b/public/avatars/badge/EVENT_ALUMNI/board.svg new file mode 100644 index 0000000000..fe17da5539 --- /dev/null +++ b/public/avatars/badge/EVENT_ALUMNI/board.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/EVENT_ALUMNI/cap.svg b/public/avatars/badge/EVENT_ALUMNI/cap.svg new file mode 100644 index 0000000000..aa9c22f3d0 --- /dev/null +++ b/public/avatars/badge/EVENT_ALUMNI/cap.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/EVENT_ALUMNI/wink.svg b/public/avatars/badge/EVENT_ALUMNI/wink.svg new file mode 100644 index 0000000000..e0fa0efefd --- /dev/null +++ b/public/avatars/badge/EVENT_ALUMNI/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/IRL_NOMADS/pack.svg b/public/avatars/badge/IRL_NOMADS/pack.svg new file mode 100644 index 0000000000..680c90abbb --- /dev/null +++ b/public/avatars/badge/IRL_NOMADS/pack.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/IRL_NOMADS/pocket.svg b/public/avatars/badge/IRL_NOMADS/pocket.svg new file mode 100644 index 0000000000..2af3e529ac --- /dev/null +++ b/public/avatars/badge/IRL_NOMADS/pocket.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/IRL_NOMADS/roll.svg b/public/avatars/badge/IRL_NOMADS/roll.svg new file mode 100644 index 0000000000..cf56143a45 --- /dev/null +++ b/public/avatars/badge/IRL_NOMADS/roll.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/NAIJA/flag.svg b/public/avatars/badge/NAIJA/flag.svg new file mode 100644 index 0000000000..58cf50e22b --- /dev/null +++ b/public/avatars/badge/NAIJA/flag.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/NAIJA/wave.svg b/public/avatars/badge/NAIJA/wave.svg new file mode 100644 index 0000000000..794fa9c376 --- /dev/null +++ b/public/avatars/badge/NAIJA/wave.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/NAIJA/wink.svg b/public/avatars/badge/NAIJA/wink.svg new file mode 100644 index 0000000000..a2a33225fc --- /dev/null +++ b/public/avatars/badge/NAIJA/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/NITA/letter.svg b/public/avatars/badge/NITA/letter.svg new file mode 100644 index 0000000000..1c218542f3 --- /dev/null +++ b/public/avatars/badge/NITA/letter.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/NITA/sparkle.svg b/public/avatars/badge/NITA/sparkle.svg new file mode 100644 index 0000000000..b5ddc46b30 --- /dev/null +++ b/public/avatars/badge/NITA/sparkle.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/NITA/wink.svg b/public/avatars/badge/NITA/wink.svg new file mode 100644 index 0000000000..4b8fcd96af --- /dev/null +++ b/public/avatars/badge/NITA/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/OG_2025_10_12/coin.svg b/public/avatars/badge/OG_2025_10_12/coin.svg new file mode 100644 index 0000000000..a3f503635a --- /dev/null +++ b/public/avatars/badge/OG_2025_10_12/coin.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/OG_2025_10_12/link.svg b/public/avatars/badge/OG_2025_10_12/link.svg new file mode 100644 index 0000000000..d942e88fdf --- /dev/null +++ b/public/avatars/badge/OG_2025_10_12/link.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/OG_2025_10_12/shades.svg b/public/avatars/badge/OG_2025_10_12/shades.svg new file mode 100644 index 0000000000..b43a0b898c --- /dev/null +++ b/public/avatars/badge/OG_2025_10_12/shades.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SHHHHH/lips.svg b/public/avatars/badge/SHHHHH/lips.svg new file mode 100644 index 0000000000..b27c161667 --- /dev/null +++ b/public/avatars/badge/SHHHHH/lips.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SHHHHH/shush.svg b/public/avatars/badge/SHHHHH/shush.svg new file mode 100644 index 0000000000..120adcfb5e --- /dev/null +++ b/public/avatars/badge/SHHHHH/shush.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SHHHHH/wink.svg b/public/avatars/badge/SHHHHH/wink.svg new file mode 100644 index 0000000000..109216f6d4 --- /dev/null +++ b/public/avatars/badge/SHHHHH/wink.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg b/public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg new file mode 100644 index 0000000000..8427a6a51d --- /dev/null +++ b/public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/peek.svg b/public/avatars/badge/SUPPORT_SURVIVOR/peek.svg new file mode 100644 index 0000000000..6afc101eb9 --- /dev/null +++ b/public/avatars/badge/SUPPORT_SURVIVOR/peek.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/shell.svg b/public/avatars/badge/SUPPORT_SURVIVOR/shell.svg new file mode 100644 index 0000000000..cbfe894fa4 --- /dev/null +++ b/public/avatars/badge/SUPPORT_SURVIVOR/shell.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/WAITLIST_SKIP/key.svg b/public/avatars/badge/WAITLIST_SKIP/key.svg new file mode 100644 index 0000000000..c979f7455f --- /dev/null +++ b/public/avatars/badge/WAITLIST_SKIP/key.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/WAITLIST_SKIP/keyhole.svg b/public/avatars/badge/WAITLIST_SKIP/keyhole.svg new file mode 100644 index 0000000000..f256b3b8c9 --- /dev/null +++ b/public/avatars/badge/WAITLIST_SKIP/keyhole.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/WAITLIST_SKIP/wink.svg b/public/avatars/badge/WAITLIST_SKIP/wink.svg new file mode 100644 index 0000000000..2d53a5deaa --- /dev/null +++ b/public/avatars/badge/WAITLIST_SKIP/wink.svg @@ -0,0 +1 @@ + diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json index 6bd2d5ebbe..fff993e591 100644 --- a/src/types/badge-assets.json +++ b/src/types/badge-assets.json @@ -79,8 +79,23 @@ "sun" ], "badges": { + "ARBIVERSE_DEVCONNECT_BA_2025": ["crystal", "hex", "cheer"], + "BETA_TESTER": ["flask", "bubble", "wink"], "BUG_WHISPERER": ["beetle", "shell", "peek"], - "OFFRAMP_USER": ["bolt", "spark", "wink"] + "CARD_ALPHA": ["card", "swipe", "tape"], + "CARD_FIRST_SWIPE": ["card", "chip", "wink"], + "CARD_SPENT_1K": ["stack", "note", "cheer"], + "DEVCONNECT_BA_2025": ["sun", "rise", "wink"], + "ETHFLORIPA_HUB": ["eth", "island", "palm"], + "EVENT_ALUMNI": ["cap", "board", "wink"], + "IRL_NOMADS": ["pack", "roll", "pocket"], + "NAIJA": ["flag", "wave", "wink"], + "NITA": ["letter", "sparkle", "wink"], + "OFFRAMP_USER": ["bolt", "spark", "wink"], + "OG_2025_10_12": ["coin", "link", "shades"], + "SHHHHH": ["lips", "shush", "wink"], + "SUPPORT_SURVIVOR": ["beetle", "shell", "peek"], + "WAITLIST_SKIP": ["key", "keyhole", "wink"] } } } From b5a297dcf762086ee15d0d309fd1d03af4d38390 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 23:22:26 +0100 Subject: [PATCH 08/17] fix(avatars): Support Survivor gets its own set Support Survivor shared Bug Whisperer's three avatars, so the 14 users who hold both saw six identical tiles. Bandage, headset and beer are its own (API bbec8dd4 declares the same slugs). --- public/avatars/badge/SUPPORT_SURVIVOR/bandage.svg | 1 + public/avatars/badge/SUPPORT_SURVIVOR/beer.svg | 1 + public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg | 1 - public/avatars/badge/SUPPORT_SURVIVOR/headset.svg | 1 + public/avatars/badge/SUPPORT_SURVIVOR/peek.svg | 1 - public/avatars/badge/SUPPORT_SURVIVOR/shell.svg | 1 - src/types/badge-assets.json | 2 +- 7 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/bandage.svg create mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/beer.svg delete mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg create mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/headset.svg delete mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/peek.svg delete mode 100644 public/avatars/badge/SUPPORT_SURVIVOR/shell.svg diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/bandage.svg b/public/avatars/badge/SUPPORT_SURVIVOR/bandage.svg new file mode 100644 index 0000000000..7498157dab --- /dev/null +++ b/public/avatars/badge/SUPPORT_SURVIVOR/bandage.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/beer.svg b/public/avatars/badge/SUPPORT_SURVIVOR/beer.svg new file mode 100644 index 0000000000..31e83f42ce --- /dev/null +++ b/public/avatars/badge/SUPPORT_SURVIVOR/beer.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg b/public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg deleted file mode 100644 index 8427a6a51d..0000000000 --- a/public/avatars/badge/SUPPORT_SURVIVOR/beetle.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/headset.svg b/public/avatars/badge/SUPPORT_SURVIVOR/headset.svg new file mode 100644 index 0000000000..b686726c8e --- /dev/null +++ b/public/avatars/badge/SUPPORT_SURVIVOR/headset.svg @@ -0,0 +1 @@ + diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/peek.svg b/public/avatars/badge/SUPPORT_SURVIVOR/peek.svg deleted file mode 100644 index 6afc101eb9..0000000000 --- a/public/avatars/badge/SUPPORT_SURVIVOR/peek.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/avatars/badge/SUPPORT_SURVIVOR/shell.svg b/public/avatars/badge/SUPPORT_SURVIVOR/shell.svg deleted file mode 100644 index cbfe894fa4..0000000000 --- a/public/avatars/badge/SUPPORT_SURVIVOR/shell.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json index fff993e591..cfe9aae32d 100644 --- a/src/types/badge-assets.json +++ b/src/types/badge-assets.json @@ -94,7 +94,7 @@ "OFFRAMP_USER": ["bolt", "spark", "wink"], "OG_2025_10_12": ["coin", "link", "shades"], "SHHHHH": ["lips", "shush", "wink"], - "SUPPORT_SURVIVOR": ["beetle", "shell", "peek"], + "SUPPORT_SURVIVOR": ["bandage", "headset", "beer"], "WAITLIST_SKIP": ["key", "keyhole", "wink"] } } From fefef4f11f0b91d9fc8ec557a9da434e9722a20f Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 23:35:29 +0100 Subject: [PATCH 09/17] fix(avatars): review batch for #2929 Guard manifest lookups against prototype names; pending-override save with one refetch per burst; roving focus from the pick; one source for the picker URL param; human tile labels and an accessible picture branch; explicit toast tap targets; shared avatar size map; fixtureHref for query-carrying fixture routes; DS shadow token on the selected tile. The manifest now lists every catalog badge, [] where art is pending. --- e2e/shots/fixtures.spec.ts | 5 +- src/app/dev/fixtures/FixtureList.tsx | 5 +- src/components/Avatar/UserAvatar.tsx | 26 +- .../Avatar/__tests__/UserAvatar.test.tsx | 4 +- .../Avatar/__tests__/avatar.utils.test.ts | 31 +- src/components/Avatar/avatar.consts.ts | 8 + src/components/Avatar/avatar.utils.ts | 38 ++- src/components/Avatar/avatarPicker.utils.ts | 19 ++ src/components/Badges/BadgeEarnToast.tsx | 9 +- .../Badges/__tests__/BadgeEarnToast.test.tsx | 2 +- src/components/Profile/AvatarWithBadge.tsx | 26 +- src/components/Profile/avatar-size.consts.ts | 14 + .../Profile/components/ProfileHeader.tsx | 5 +- src/components/Profile/index.tsx | 10 +- src/dev/fixtures/__tests__/fixtures.test.ts | 2 +- src/dev/fixtures/active.ts | 7 + src/dev/fixtures/registry.ts | 3 +- src/i18n/app/messages/en.json | 7 +- src/i18n/app/messages/en.marketing.json | 3 +- src/i18n/app/messages/es-419.json | 7 +- src/i18n/app/messages/es-419.marketing.json | 3 +- src/i18n/app/messages/pt-BR.json | 7 +- src/i18n/app/messages/pt-BR.marketing.json | 3 +- src/types/badge-assets.json | 303 ++++++++++++------ 24 files changed, 371 insertions(+), 176 deletions(-) create mode 100644 src/components/Avatar/avatar.consts.ts create mode 100644 src/components/Avatar/avatarPicker.utils.ts create mode 100644 src/components/Profile/avatar-size.consts.ts diff --git a/e2e/shots/fixtures.spec.ts b/e2e/shots/fixtures.spec.ts index 9bba825ae2..1135f6d712 100644 --- a/e2e/shots/fixtures.spec.ts +++ b/e2e/shots/fixtures.spec.ts @@ -11,7 +11,7 @@ import { expect, test, type Page } from '@playwright/test' import { mkdir } from 'node:fs/promises' import { join } from 'node:path' -import { FIXTURE_PARAM, FIXTURE_STORAGE_KEY } from '../../src/dev/fixtures/active' +import { FIXTURE_PARAM, FIXTURE_STORAGE_KEY, fixtureHref } from '../../src/dev/fixtures/active' import { FIXTURES } from '../../src/dev/fixtures/registry' const OUT_DIR = process.env.SHOTS_OUT ?? 'e2e/__shots__/current' @@ -110,8 +110,7 @@ for (const [name, fixture] of Object.entries(FIXTURES)) { await page.clock.setFixedTime(FROZEN_NOW) await page.addInitScript(seenOnceModals) - const sep = fixture.route.includes('?') ? '&' : '?' - await page.goto(`${fixture.route}${sep}${FIXTURE_PARAM}=${name}`, { waitUntil: 'domcontentloaded' }) + await page.goto(fixtureHref(fixture.route, name), { waitUntil: 'domcontentloaded' }) await settle(page) // A build without NEXT_PUBLIC_VERCEL_ENV=preview ignores the param and diff --git a/src/app/dev/fixtures/FixtureList.tsx b/src/app/dev/fixtures/FixtureList.tsx index 520f2b7fd2..63ddd7d496 100644 --- a/src/app/dev/fixtures/FixtureList.tsx +++ b/src/app/dev/fixtures/FixtureList.tsx @@ -3,7 +3,7 @@ import Card from '@/components/Global/Card' import DevNoteCard from '@/app/(mobile-ui)/dev/_components/DevNoteCard' import DevPageShell from '@/app/(mobile-ui)/dev/_components/DevPageShell' -import { clearFixture, FIXTURE_PARAM } from '@/dev/fixtures/active' +import { clearFixture, FIXTURE_PARAM, fixtureHref } from '@/dev/fixtures/active' import { FIXTURES } from '@/dev/fixtures/registry' export default function FixtureList() { @@ -20,8 +20,7 @@ export default function FixtureList() { const fixture = FIXTURES[name] // Plain : a soft navigation would keep the previous fixture's // React Query cache, so the screen would show stale state. - // a route may carry its own query (nuqs URL state) - const href = `${fixture.route}${fixture.route.includes('?') ? '&' : '?'}${FIXTURE_PARAM}=${name}` + const href = fixtureHref(fixture.route, name) return ( diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx index 4875c98f1c..0c9641a57d 100644 --- a/src/components/Avatar/UserAvatar.tsx +++ b/src/components/Avatar/UserAvatar.tsx @@ -1,7 +1,9 @@ 'use client' import Image from 'next/image' -import AvatarWithBadge, { type AvatarSize } from '@/components/Profile/AvatarWithBadge' +import { useTranslations } from 'next-intl' +import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' +import { AVATAR_SIZE_CLASSES, type AvatarSize } from '@/components/Profile/avatar-size.consts' import { twMerge } from '@/utils/tw' import { avatarPaletteClass, avatarSrc } from './avatar.utils' @@ -13,23 +15,13 @@ interface UserAvatarProps { className?: string } -// board 17802:61529: XS 24 · S 32 · M 48 · L 64 (+ the code-only 96), circle, -// 1px border — the same boxes AvatarWithBadge draws. -const SIZE_CLASSES: Record = { - tiny: 'size-6', - 'extra-small': 'size-8', - small: 'size-12', - medium: 'size-16', - large: 'size-24', -} -const SIZE_PX: Record = { tiny: 24, 'extra-small': 32, small: 48, medium: 64, large: 96 } - /** * The user's own avatar (TASK-22142): the picked character on its palette - * triple. Without a pick — or with a key the manifest does not know — it is + * triple. Without a pick, or with a key the manifest does not know, it is * exactly the existing first-letter avatar, so the fallback lives in one place. */ export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: UserAvatarProps) { + const t = useTranslations('common') const src = avatarSrc(avatarKey) if (!src || !avatarKey) { return name ? ( @@ -49,15 +41,17 @@ export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: return ( - + ) } diff --git a/src/components/Avatar/__tests__/UserAvatar.test.tsx b/src/components/Avatar/__tests__/UserAvatar.test.tsx index 93732e79f3..b4cc7ec905 100644 --- a/src/components/Avatar/__tests__/UserAvatar.test.tsx +++ b/src/components/Avatar/__tests__/UserAvatar.test.tsx @@ -1,5 +1,6 @@ -import { render } from '@testing-library/react' +import { screen } from '@testing-library/react' import type { ComponentProps } from 'react' +import { renderWithIntl as render } from '@/test-utils/intl' import { UserAvatar } from '../UserAvatar' jest.mock('next/image', () => ({ @@ -12,6 +13,7 @@ describe('UserAvatar', () => { const { container } = render() expect(container.querySelector('img')).toHaveAttribute('src', '/avatars/basic/frog.svg') + expect(screen.getByRole('img', { name: 'Avatar for satoshi' })).toBeInTheDocument() expect(container).not.toHaveTextContent('S') }) diff --git a/src/components/Avatar/__tests__/avatar.utils.test.ts b/src/components/Avatar/__tests__/avatar.utils.test.ts index 1a3ad86cae..60d0644f0d 100644 --- a/src/components/Avatar/__tests__/avatar.utils.test.ts +++ b/src/components/Avatar/__tests__/avatar.utils.test.ts @@ -1,7 +1,14 @@ import { existsSync } from 'fs' import { join } from 'path' import badgeAssets from '@/types/badge-assets.json' -import { avatarPaletteClass, avatarPool, avatarSrc, badgeAvatarKeys, basicAvatarKeys } from '../avatar.utils' +import { + avatarPaletteClass, + avatarPool, + avatarSrc, + badgeAvatarKeys, + basicAvatarKeys, + offerBasics, +} from '../avatar.utils' describe('avatar catalog', () => { // the manifest is the API's contract: every slug it names must be real art @@ -23,6 +30,7 @@ describe('avatar catalog', () => { expect(basicAvatarKeys()).toHaveLength(20) expect(basicAvatarKeys()[0]).toBe('basic.apple') expect(badgeAvatarKeys([])).toEqual([]) + // a catalog badge without art is listed as [] in the manifest: nothing unlocked expect(badgeAvatarKeys(['FIRST_INVITE', 'NOT_A_BADGE'])).toEqual([]) expect(badgeAvatarKeys(['BUG_WHISPERER'])).toEqual([ 'badge.BUG_WHISPERER.beetle', @@ -39,10 +47,31 @@ describe('avatar catalog', () => { expect(avatarSrc('badge.BUG_WHISPERER.nope')).toBeNull() expect(avatarSrc('badge.FIRST_INVITE.beetle')).toBeNull() expect(avatarSrc('../etc/passwd')).toBeNull() + // plain JSON object: prototype names must not read as badges + expect(avatarSrc('badge.constructor.x')).toBeNull() + expect(badgeAvatarKeys(['constructor', 'toString', '__proto__'])).toEqual([]) expect(avatarSrc(null)).toBeNull() expect(avatarSrc(undefined)).toBeNull() }) + it('offers one row of five basics that always holds the pick', () => { + const seeded = (seed: number) => () => (seed = (seed * 9301 + 49297) % 233280) / 233280 + const row = offerBasics('basic.sun', 5, seeded(1)) + expect(row).toHaveLength(5) + expect(row).toContain('basic.sun') + expect(new Set(row).size).toBe(5) + for (const key of row) expect(basicAvatarKeys()).toContain(key) + + // the dice deals a different row and never touches the pick + const rerolled = offerBasics('basic.sun', 5, seeded(2)) + expect(rerolled).not.toEqual(row) + expect(rerolled).toContain('basic.sun') + + // a badge pick is not a basic: five random basics, nothing kept + expect(offerBasics('badge.BUG_WHISPERER.beetle', 5, seeded(3))).toHaveLength(5) + expect(offerBasics(null, 5, seeded(4))).toHaveLength(5) + }) + it('keeps a stable palette per key, from the seven avatar triples', () => { expect(avatarPaletteClass('basic.apple')).toBe(avatarPaletteClass('basic.apple')) expect(avatarPaletteClass('basic.apple')).toMatch( diff --git a/src/components/Avatar/avatar.consts.ts b/src/components/Avatar/avatar.consts.ts new file mode 100644 index 0000000000..679842a1ef --- /dev/null +++ b/src/components/Avatar/avatar.consts.ts @@ -0,0 +1,8 @@ +import { parseAsBoolean } from 'nuqs' + +/** nuqs URL state of the profile avatar picker (TASK-22142). One source for + * the profile page, the badge-earned toast deep link and the fixtures. */ +export const AVATAR_PICKER_PARAM = 'avatarPicker' +// clearOnDefault (nuqs 2 default) keeps the URL clean once the drawer closes +export const avatarPickerParser = parseAsBoolean.withDefault(false) +export const AVATAR_PICKER_PATH = `/profile?${AVATAR_PICKER_PARAM}=true` diff --git a/src/components/Avatar/avatar.utils.ts b/src/components/Avatar/avatar.utils.ts index 71c21d7e06..9c37f2e5ee 100644 --- a/src/components/Avatar/avatar.utils.ts +++ b/src/components/Avatar/avatar.utils.ts @@ -1,23 +1,27 @@ /** - * Profile avatars (TASK-22142). The catalog is the API's badge manifest - * (`badge-assets.json`, regenerated by `pnpm badge:check --write-manifest` - * in peanut-api-ts — never hand-edited): `avatars.basics` is the set every - * user gets, `avatars.badges[CODE]` the slugs holding that badge unlocks. + * Profile avatars (TASK-22142). The catalog is the API's badge manifest, + * `badge-assets.json`, regenerated by `pnpm badge:check --write-manifest` + * in peanut-api-ts and never hand-edited. `avatars.basics` is the set every + * user gets; `avatars.badges[CODE]` lists the slugs holding that badge unlocks. * - * Keys are `basic.` and `badge..`; the API validates a - * pick against the same pool, so this file never decides who may wear what — - * it only mirrors the manifest into paths and palettes. + * Keys are `basic.` and `badge..`. The API validates a + * pick against the same pool. This file only mirrors the manifest into + * paths and palettes; it never decides who may wear what. */ import badgeAssets from '@/types/badge-assets.json' const BASICS: readonly string[] = badgeAssets.avatars.basics const BADGE_AVATARS: Readonly> = badgeAssets.avatars.badges +// plain JSON object: a code like `constructor` must read as "no avatars", not +// as Object.prototype +const slugsOf = (code: string): readonly string[] => (Object.hasOwn(BADGE_AVATARS, code) ? BADGE_AVATARS[code] : []) + export const basicAvatarKeys = (): string[] => BASICS.map((slug) => `basic.${slug}`) /** Avatar keys unlocked by holding these badge codes, in badge order. */ export const badgeAvatarKeys = (heldCodes: readonly string[]): string[] => - heldCodes.flatMap((code) => (BADGE_AVATARS[code] ?? []).map((slug) => `badge.${code}.${slug}`)) + heldCodes.flatMap((code) => slugsOf(code).map((slug) => `badge.${code}.${slug}`)) /** Everything the user may pick: the basics plus what their badges unlock. */ export const avatarPool = (heldCodes: readonly string[]): string[] => [ @@ -25,12 +29,28 @@ export const avatarPool = (heldCodes: readonly string[]): string[] => [ ...badgeAvatarKeys(heldCodes), ] +/** + * The basics row the picker offers: the current pick if it is a basic, then + * random basics to fill `n`. The dice rerolls this row and never the pick + * (Split's semantics: the dice changes what is offered, not who you are). + */ +export function offerBasics(pick: string | null, n = 5, random: () => number = Math.random): string[] { + const basics = basicAvatarKeys() + const keep = pick && basics.includes(pick) ? [pick] : [] + const rest = basics.filter((key) => key !== pick) + for (let i = rest.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)) + ;[rest[i], rest[j]] = [rest[j], rest[i]] + } + return [...keep, ...rest].slice(0, n) +} + /** Public path of the avatar art, or null for a key the manifest does not know. */ export function avatarSrc(key: string | null | undefined): string | null { if (!key) return null const [kind, ...rest] = key.split('.') if (kind === 'basic' && rest.length === 1 && BASICS.includes(rest[0])) return `/avatars/basic/${rest[0]}.svg` - if (kind === 'badge' && rest.length === 2 && BADGE_AVATARS[rest[0]]?.includes(rest[1])) { + if (kind === 'badge' && rest.length === 2 && slugsOf(rest[0]).includes(rest[1])) { return `/avatars/badge/${rest[0]}/${rest[1]}.svg` } return null diff --git a/src/components/Avatar/avatarPicker.utils.ts b/src/components/Avatar/avatarPicker.utils.ts new file mode 100644 index 0000000000..332c258abc --- /dev/null +++ b/src/components/Avatar/avatarPicker.utils.ts @@ -0,0 +1,19 @@ +import type { KeyboardEvent } from 'react' + +export const AVATAR_PICKER_COLUMNS = 5 + +/** One tab stop per radiogroup; arrows move between tiles and wrap. */ +export function roveAvatarTiles(event: KeyboardEvent): void { + const step = { + ArrowRight: 1, + ArrowLeft: -1, + ArrowDown: AVATAR_PICKER_COLUMNS, + ArrowUp: -AVATAR_PICKER_COLUMNS, + }[event.key] + if (!step) return + const radios = Array.from(event.currentTarget.querySelectorAll('[role="radio"]')) + const index = radios.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + event.preventDefault() + radios[(index + step + radios.length) % radios.length].focus() +} diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx index 1f2d22188e..1d119503b2 100644 --- a/src/components/Badges/BadgeEarnToast.tsx +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -28,10 +28,9 @@ import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { BadgeImage } from '@/components/Badges/BadgeImage' import { badgeAvatarKeys } from '@/components/Avatar/avatar.utils' +import { AVATAR_PICKER_PATH } from '@/components/Avatar/avatar.consts' const HOME_PATH = '/home' -// the picker opens from the profile page; `avatarPicker` is its nuqs URL state -const AVATAR_PICKER_PATH = '/profile?avatarPicker=true' type ModalBadge = { code: string; title: string; description: string; logo: string } @@ -69,7 +68,7 @@ export default function BadgeEarnToast() { const openInspect = () => { dismiss(toastId) liveToastIdRef.current = null - posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count }) + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count, target: 'badge_detail' }) if (count === 1) { setModalBadge({ code: newest.code, @@ -85,8 +84,8 @@ export default function BadgeEarnToast() { const label = count === 1 ? t('toastSingle', { name: newestName }) : t('toastMultiple', { count }) // A badge that ships avatars (TASK-22142) announces the unlock and - // hands the user straight to the picker — the badge tap keeps its - // detail view, so the two are separate controls, not one. + // links to the picker. The badge tap keeps its detail view, so these + // are two controls. const avatarCount = badgeAvatarKeys(codes).length const chooseAvatar = () => { dismiss(toastId) diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx index 2ab30069f3..57aaefede1 100644 --- a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -95,7 +95,7 @@ describe('BadgeEarnToast', () => { act(() => fireEvent.click(screen.getByRole('button', { name: /tap to view/ }))) expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT') - expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_tapped', { count: 1 }) + expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_tapped', { count: 1, target: 'badge_detail' }) expect(screen.getByTestId('badge-detail-modal')).toHaveTextContent('Product Hunt') expect(screen.getByTestId('badge-detail-modal')).toHaveAttribute('data-code', 'PRODUCT_HUNT') expect(mockRouterPush).not.toHaveBeenCalled() diff --git a/src/components/Profile/AvatarWithBadge.tsx b/src/components/Profile/AvatarWithBadge.tsx index ef7759590c..87039c784e 100644 --- a/src/components/Profile/AvatarWithBadge.tsx +++ b/src/components/Profile/AvatarWithBadge.tsx @@ -4,8 +4,9 @@ import React, { useMemo, useState } from 'react' import { twMerge } from '@/utils/tw' import { Icon, type IconName } from '../Global/Icons/Icon' import Image, { type StaticImageData } from 'next/image' +import { AVATAR_SIZE_CLASSES, type AvatarSize } from './avatar-size.consts' -export type AvatarSize = 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' +export type { AvatarSize } /** * props for the avatarwithbadge component. @@ -50,19 +51,6 @@ const AvatarWithBadge: React.FC = ({ firstLetterOnly, }) => { const [logoFailed, setLogoFailed] = useState(false) - // board 17802:61529 sizes XS/S/M/L are 24/32/48/64 — the boxes here already - // matched, under different names, but every initials step was raw stock - // type and none of the five sat on the DS scale. Board type per box: - // 24 and 32 = Label/M, 48 = Body/M-SemiBold, 64 = Heading/S. `large` (96) - // has no board row and takes the next heading step up. - const sizeClasses: Record = { - tiny: 'h-6 w-6 text-label-m', - 'extra-small': 'h-8 w-8 text-label-m', - small: 'h-12 w-12 text-body-m-semibold', - medium: 'h-16 w-16 text-heading-s', - large: 'h-24 w-24 text-heading-m', - } - const iconSizeMap: Record = { tiny: 12, 'extra-small': 16, @@ -79,7 +67,13 @@ const AvatarWithBadge: React.FC = ({ if (logo && !logoFailed) { return (
-
+
{name = ({ // weights (Label/M is 800, Body/M-SemiBold 600, Heading/S // 800) and a blanket bold rendered all three at 700. `flex items-center justify-center rounded-full`, - sizeClasses[size], + AVATAR_SIZE_CLASSES[size], className )} // apply dynamic styles (e.g., background color) diff --git a/src/components/Profile/avatar-size.consts.ts b/src/components/Profile/avatar-size.consts.ts new file mode 100644 index 0000000000..1f76187044 --- /dev/null +++ b/src/components/Profile/avatar-size.consts.ts @@ -0,0 +1,14 @@ +export type AvatarSize = 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' + +// board 17802:61529 sizes XS/S/M/L are 24/32/48/64 — the boxes here already +// matched, under different names, but every initials step was raw stock +// type and none of the five sat on the DS scale. Board type per box: +// 24 and 32 = Label/M, 48 = Body/M-SemiBold, 64 = Heading/S. `large` (96) +// has no board row and takes the next heading step up. +export const AVATAR_SIZE_CLASSES: Record = { + tiny: 'h-6 w-6 text-label-m', + 'extra-small': 'h-8 w-8 text-label-m', + small: 'h-12 w-12 text-body-m-semibold', + medium: 'h-16 w-16 text-heading-s', + large: 'h-24 w-24 text-heading-m', +} diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index 8c43ae1407..5a0f5cea88 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -44,6 +44,7 @@ const ProfileHeader: React.FC = ({ const { isVerified: selfIsIdentityVerified } = useIdentityVerification() const isAuthenticatedUserVerified = selfIsIdentityVerified && authenticatedUser?.user.username === username const isSelfProfile = authenticatedUser?.user.username?.toLowerCase() === username.toLowerCase() + const ownAvatar = // `shareableUrl` reads the live origin, so preview and staging share // themselves — the old BASE_URL import is non-null-asserted with no fallback. @@ -79,10 +80,10 @@ const ProfileHeader: React.FC = ({ aria-label={tAvatar('change')} className="rounded-full focus-visible:outline-[3px] focus-visible:outline-action-focus" > - + {ownAvatar} ) : ( - + ownAvatar ) ) : ( diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index e7a00fe2f3..689751c664 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -19,14 +19,15 @@ import { useResidenceRestrictions } from '@/hooks/useResidenceRestrictions' import InviteFriendsModal from '../Global/InviteFriendsModal' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import Image from 'next/image' -import { parseAsBoolean, useQueryState } from 'nuqs' +import { useQueryState } from 'nuqs' import { AvatarPicker } from '@/components/Avatar/AvatarPicker' +import { AVATAR_PICKER_PARAM, avatarPickerParser } from '@/components/Avatar/avatar.consts' export const Profile = () => { const { logoutUser, isLoggingOut, user } = useAuth() const [isInviteFriendsModalOpen, setIsInviteFriendsModalOpen] = useState(false) // URL state so the badge-earned toast can deep-link straight into the picker - const [avatarPickerOpen, setAvatarPickerOpen] = useQueryState('avatarPicker', parseAsBoolean) + const [avatarPickerOpen, setAvatarPickerOpen] = useQueryState(AVATAR_PICKER_PARAM, avatarPickerParser) const router = useRouter() const onBack = useSafeBack('/home') // Profile "verified" reflects identity verification only (the human was ID-verified) — NOT @@ -63,10 +64,7 @@ export const Profile = () => { isVerified={isUserSumsubKycApproved} onChangeAvatar={() => setAvatarPickerOpen(true)} /> - setAvatarPickerOpen(open ? true : null)} - /> +
{/* IA from #2834: identity/products first, then social + account, then app settings. Payment limits moved inline diff --git a/src/dev/fixtures/__tests__/fixtures.test.ts b/src/dev/fixtures/__tests__/fixtures.test.ts index 57dde0fd19..feea062813 100644 --- a/src/dev/fixtures/__tests__/fixtures.test.ts +++ b/src/dev/fixtures/__tests__/fixtures.test.ts @@ -10,7 +10,7 @@ const APP_DIR = join(process.cwd(), 'src', 'app', '(mobile-ui)') function routeExists(route: string): boolean { let dir = APP_DIR // a fixture may open a route with its own query (nuqs URL state) - for (const segment of route.split('?')[0].split('/').filter(Boolean)) { + for (const segment of new URL(route, 'http://fixture.local').pathname.split('/').filter(Boolean)) { if (existsSync(join(dir, segment))) { dir = join(dir, segment) continue diff --git a/src/dev/fixtures/active.ts b/src/dev/fixtures/active.ts index 66322d3ba6..96a1306345 100644 --- a/src/dev/fixtures/active.ts +++ b/src/dev/fixtures/active.ts @@ -5,6 +5,13 @@ import { DEV_TOOLS_ENABLED } from '@/constants/dev-tools.consts' export const FIXTURE_PARAM = '__fixture' + +/** `route?__fixture=name`, joining correctly when the route carries its own query. */ +export function fixtureHref(route: string, name: string): string { + const url = new URL(route, 'http://fixture.local') + url.searchParams.set(FIXTURE_PARAM, name) + return `${url.pathname}${url.search}` +} // exported for e2e/shots/fixtures.spec.ts, which reads it back through the // browser to prove fixture mode actually engaged before taking a screenshot. export const FIXTURE_STORAGE_KEY = 'peanut_fixture' diff --git a/src/dev/fixtures/registry.ts b/src/dev/fixtures/registry.ts index 188f0d54e6..3fae6cd224 100644 --- a/src/dev/fixtures/registry.ts +++ b/src/dev/fixtures/registry.ts @@ -10,6 +10,7 @@ // this registry replaced it. import type { Fixture } from './types' +import { AVATAR_PICKER_PATH } from '@/components/Avatar/avatar.consts' // Hugo's overflow case: a username no header was designed for, and a points // total that is nine digits with separators. @@ -342,7 +343,7 @@ export const FIXTURES: Record = { responses: { 'GET /users/me': { user: { avatarKey: 'basic.frog' } } }, }, 'avatar-picker': { - route: '/profile?avatarPicker=true', + route: AVATAR_PICKER_PATH, about: 'Avatar picker open: three Bug Whisperer avatars unlocked above the twenty basics, beetle selected.', responses: { 'GET /users/me': { diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index edf58b71df..d9b1d25797 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -55,7 +55,8 @@ "unknown": "Unknown" }, "exchangeRate": "Exchange rate", - "slideToProceed": "Slide to Proceed" + "slideToProceed": "Slide to Proceed", + "userAvatarAlt": "Avatar for {username}" }, "navigation": { "home": "Home", @@ -3582,12 +3583,12 @@ }, "avatar": { "title": "Your avatar", - "description": "Pick one, or roll the die across everything you have unlocked.", + "description": "Pick one, or roll the dice for a fresh row of basics.", "fromBadges": "From your badges", "basics": "Basics", "unlocked": "{count} unlocked", "noBadgeAvatars": "Earn a badge and its avatars appear here.", - "roll": "Roll the die", + "rollDice": "Roll the dice", "useInitial": "Use my initial instead", "change": "Change avatar", "saveFailed": "Could not save your avatar. Try again." diff --git a/src/i18n/app/messages/en.marketing.json b/src/i18n/app/messages/en.marketing.json index 8b682b636e..0bf810b264 100644 --- a/src/i18n/app/messages/en.marketing.json +++ b/src/i18n/app/messages/en.marketing.json @@ -55,7 +55,8 @@ "unknown": "Unknown" }, "exchangeRate": "Exchange rate", - "slideToProceed": "Slide to Proceed" + "slideToProceed": "Slide to Proceed", + "userAvatarAlt": "Avatar for {username}" }, "errors": { "balanceSettling": "Your balance isn't fully available yet. Please try again in a few seconds.", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index cb7b934274..88b1cbda28 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -55,7 +55,8 @@ "unknown": "Desconocido" }, "exchangeRate": "Tipo de cambio", - "slideToProceed": "Desliza para continuar" + "slideToProceed": "Desliza para continuar", + "userAvatarAlt": "Avatar de {username}" }, "navigation": { "home": "Inicio", @@ -3582,12 +3583,12 @@ }, "avatar": { "title": "Tu avatar", - "description": "Elige uno o tira el dado entre todo lo que desbloqueaste.", + "description": "Elige uno o tira los dados para ver otros básicos.", "fromBadges": "De tus insignias", "basics": "Básicos", "unlocked": "{count} desbloqueados", "noBadgeAvatars": "Gana una insignia y sus avatares aparecen aquí.", - "roll": "Tirar el dado", + "rollDice": "Tirar los dados", "useInitial": "Usar mi inicial", "change": "Cambiar avatar", "saveFailed": "No pudimos guardar tu avatar. Inténtalo de nuevo." diff --git a/src/i18n/app/messages/es-419.marketing.json b/src/i18n/app/messages/es-419.marketing.json index f8f1db8694..9f93c26f7e 100644 --- a/src/i18n/app/messages/es-419.marketing.json +++ b/src/i18n/app/messages/es-419.marketing.json @@ -55,7 +55,8 @@ "unknown": "Desconocido" }, "exchangeRate": "Tipo de cambio", - "slideToProceed": "Desliza para continuar" + "slideToProceed": "Desliza para continuar", + "userAvatarAlt": "Avatar de {username}" }, "errors": { "balanceSettling": "Tu saldo aún no está totalmente disponible. Inténtalo de nuevo en unos segundos.", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index e1a1d504c7..8790ff90e4 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -55,7 +55,8 @@ "unknown": "Desconhecido" }, "exchangeRate": "Taxa de câmbio", - "slideToProceed": "Deslize para continuar" + "slideToProceed": "Deslize para continuar", + "userAvatarAlt": "Avatar de {username}" }, "navigation": { "home": "Início", @@ -3582,12 +3583,12 @@ }, "avatar": { "title": "Seu avatar", - "description": "Escolha um ou jogue o dado entre tudo o que você desbloqueou.", + "description": "Escolha um ou jogue os dados para ver outros básicos.", "fromBadges": "Dos seus selos", "basics": "Básicos", "unlocked": "{count} desbloqueados", "noBadgeAvatars": "Ganhe um selo e seus avatares aparecem aqui.", - "roll": "Jogar o dado", + "rollDice": "Jogar os dados", "useInitial": "Usar minha inicial", "change": "Trocar avatar", "saveFailed": "Não foi possível salvar seu avatar. Tente de novo." diff --git a/src/i18n/app/messages/pt-BR.marketing.json b/src/i18n/app/messages/pt-BR.marketing.json index 457c76fdce..2527512fbd 100644 --- a/src/i18n/app/messages/pt-BR.marketing.json +++ b/src/i18n/app/messages/pt-BR.marketing.json @@ -55,7 +55,8 @@ "unknown": "Desconhecido" }, "exchangeRate": "Taxa de câmbio", - "slideToProceed": "Deslize para continuar" + "slideToProceed": "Deslize para continuar", + "userAvatarAlt": "Avatar de {username}" }, "errors": { "balanceSettling": "Seu saldo ainda não está totalmente disponível. Tente novamente em alguns segundos.", diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json index cfe9aae32d..eede5c93ee 100644 --- a/src/types/badge-assets.json +++ b/src/types/badge-assets.json @@ -1,101 +1,206 @@ { - "assets": { - "ACAI_POWERED": "/badges/acai_powered.svg", - "ARBITRUM": "/badges/arbitrum.svg", - "ARBIVERSE_DEVCONNECT_BA_2025": "/badges/arbiverse_devconnect.svg", - "BETA_TESTER": "/badges/beta_tester.svg", - "BIGGEST_REQUEST_POT": "/badges/biggest_request_pot.svg", - "BIG_SPENDER_5K": "/badges/big_spender.svg", - "BUG_WHISPERER": "/badges/bug_whisperer.svg", - "CARD_ALPHA": "/badges/card_alpha.svg", - "CARD_CLOSED_BETA": "/badges/card_closed_beta.svg", - "CARD_FIRST_SWIPE": "/badges/happy_card.svg", - "CARD_PIONEER": "/badges/founding_pioneer.svg", - "CARD_SPENT_1K": "/badges/money_stack.svg", - "CERTIFIED_YAPPER": "/badges/certified_yapper.svg", - "DEVCONNECT_BA_2025": "/badges/devconnect_2025.svg", - "DOUBLE_DIGITS": "/badges/double_digits.svg", - "DUNBAR": "/badges/dunbar.svg", - "ENS": "/badges/ens.svg", - "ETHFLORIPA_HUB": "/badges/ethfloripa_hub.svg", - "EVENT_ALUMNI": "/badges/event_alumni.svg", - "FESTA_JUNINA_2026": "/badges/festa_junina_2026.svg", - "FIRST_CRUMB": "/badges/first_crumb.svg", - "FIRST_INVITE": "/badges/first_invite.svg", - "FOUNDER_HOUSE": "/badges/founder_house.svg", - "FOUNDING_PIONEER": "/badges/founding_pioneer.svg", - "GIGA_YAPPER": "/badges/giga_yapper.svg", - "INFLUENCER_25": "/badges/influencer_25.svg", - "IRL_NOMADS": "/badges/irl_nomads.svg", - "MANICERO": "/badges/manicero.svg", - "MEGA_INFLUENCER": "/badges/invites_100.svg", - "MINI_INFLUENCER": "/badges/mini_influencer.svg", - "MOST_INVITES": "/badges/most_invites.svg", - "MOST_PAYMENTS_DEVCON": "/badges/most_payments.svg", - "MOST_RESTAURANTS_DEVCON": "/badges/foodie.svg", - "NAIJA": "/badges/naija.svg", - "NITA": "/badges/nita.svg", - "NOT_SO_SHHHH": "/badges/not_so_shhhh.svg", - "OFFRAMP_USER": "/badges/offramp_user.png", - "OG_2025_10_12": "/badges/og_v1.svg", - "PEANUT_SHAPER": "/badges/peanut_shaper.png", - "PRODUCT_HUNT": "/badges/product_hunt.svg", - "PSYOPS_DIVISION": "/badges/psyops_division.svg", - "SECOND_INVITE": "/badges/second_invite.svg", - "SEEDLING_DEVCONNECT_BA_2025": "/badges/seedlings_devconnect.svg", - "SHHHHH": "/badges/shhhhh.svg", - "SPLITTER": "/badges/splitter.svg", - "SUPPORT_SURVIVOR": "/badges/bug_whisperer.svg", - "SURF_UP": "/badges/surf_up.svg", - "TERERE": "/badges/terere.svg", - "THIRD_INVITE": "/badges/third_invite.svg", - "TOKEN_NATION_SP_2026": "/badges/token_nation_2026.svg", - "TOUCHED_GRASS": "/badges/touched_grass.svg", - "TRON": "/badges/tron.svg", - "VERIFIED": "/badges/verified.svg", - "WAITLIST_SKIP": "/badges/skip_pass.svg" - }, - "avatars": { - "basics": [ - "apple", - "avocado", - "cactus", - "cloud", - "cube", - "donut", - "drop", - "egg", - "fish", - "flower", - "frog", - "gem", - "ghost", - "heart", - "leaf", - "moon", - "mushroom", - "planet", - "star", - "sun" - ], - "badges": { - "ARBIVERSE_DEVCONNECT_BA_2025": ["crystal", "hex", "cheer"], - "BETA_TESTER": ["flask", "bubble", "wink"], - "BUG_WHISPERER": ["beetle", "shell", "peek"], - "CARD_ALPHA": ["card", "swipe", "tape"], - "CARD_FIRST_SWIPE": ["card", "chip", "wink"], - "CARD_SPENT_1K": ["stack", "note", "cheer"], - "DEVCONNECT_BA_2025": ["sun", "rise", "wink"], - "ETHFLORIPA_HUB": ["eth", "island", "palm"], - "EVENT_ALUMNI": ["cap", "board", "wink"], - "IRL_NOMADS": ["pack", "roll", "pocket"], - "NAIJA": ["flag", "wave", "wink"], - "NITA": ["letter", "sparkle", "wink"], - "OFFRAMP_USER": ["bolt", "spark", "wink"], - "OG_2025_10_12": ["coin", "link", "shades"], - "SHHHHH": ["lips", "shush", "wink"], - "SUPPORT_SURVIVOR": ["bandage", "headset", "beer"], - "WAITLIST_SKIP": ["key", "keyhole", "wink"] - } - } + "assets": { + "ACAI_POWERED": "/badges/acai_powered.svg", + "ARBITRUM": "/badges/arbitrum.svg", + "ARBIVERSE_DEVCONNECT_BA_2025": "/badges/arbiverse_devconnect.svg", + "BETA_TESTER": "/badges/beta_tester.svg", + "BIGGEST_REQUEST_POT": "/badges/biggest_request_pot.svg", + "BIG_SPENDER_5K": "/badges/big_spender.svg", + "BUG_WHISPERER": "/badges/bug_whisperer.svg", + "CARD_ALPHA": "/badges/card_alpha.svg", + "CARD_CLOSED_BETA": "/badges/card_closed_beta.svg", + "CARD_FIRST_SWIPE": "/badges/happy_card.svg", + "CARD_PIONEER": "/badges/founding_pioneer.svg", + "CARD_SPENT_1K": "/badges/money_stack.svg", + "CERTIFIED_YAPPER": "/badges/certified_yapper.svg", + "DEVCONNECT_BA_2025": "/badges/devconnect_2025.svg", + "DOUBLE_DIGITS": "/badges/double_digits.svg", + "DUNBAR": "/badges/dunbar.svg", + "ENS": "/badges/ens.svg", + "ETHFLORIPA_HUB": "/badges/ethfloripa_hub.svg", + "EVENT_ALUMNI": "/badges/event_alumni.svg", + "FESTA_JUNINA_2026": "/badges/festa_junina_2026.svg", + "FIRST_CRUMB": "/badges/first_crumb.svg", + "FIRST_INVITE": "/badges/first_invite.svg", + "FOUNDER_HOUSE": "/badges/founder_house.svg", + "FOUNDING_PIONEER": "/badges/founding_pioneer.svg", + "GIGA_YAPPER": "/badges/giga_yapper.svg", + "INFLUENCER_25": "/badges/influencer_25.svg", + "IRL_NOMADS": "/badges/irl_nomads.svg", + "MANICERO": "/badges/manicero.svg", + "MEGA_INFLUENCER": "/badges/invites_100.svg", + "MINI_INFLUENCER": "/badges/mini_influencer.svg", + "MOST_INVITES": "/badges/most_invites.svg", + "MOST_PAYMENTS_DEVCON": "/badges/most_payments.svg", + "MOST_RESTAURANTS_DEVCON": "/badges/foodie.svg", + "NAIJA": "/badges/naija.svg", + "NITA": "/badges/nita.svg", + "NOT_SO_SHHHH": "/badges/not_so_shhhh.svg", + "OFFRAMP_USER": "/badges/offramp_user.png", + "OG_2025_10_12": "/badges/og_v1.svg", + "PEANUT_SHAPER": "/badges/peanut_shaper.png", + "PRODUCT_HUNT": "/badges/product_hunt.svg", + "PSYOPS_DIVISION": "/badges/psyops_division.svg", + "SECOND_INVITE": "/badges/second_invite.svg", + "SEEDLING_DEVCONNECT_BA_2025": "/badges/seedlings_devconnect.svg", + "SHHHHH": "/badges/shhhhh.svg", + "SPLITTER": "/badges/splitter.svg", + "SUPPORT_SURVIVOR": "/badges/bug_whisperer.svg", + "SURF_UP": "/badges/surf_up.svg", + "TERERE": "/badges/terere.svg", + "THIRD_INVITE": "/badges/third_invite.svg", + "TOKEN_NATION_SP_2026": "/badges/token_nation_2026.svg", + "TOUCHED_GRASS": "/badges/touched_grass.svg", + "TRON": "/badges/tron.svg", + "VERIFIED": "/badges/verified.svg", + "WAITLIST_SKIP": "/badges/skip_pass.svg" + }, + "avatars": { + "basics": [ + "apple", + "avocado", + "cactus", + "cloud", + "cube", + "donut", + "drop", + "egg", + "fish", + "flower", + "frog", + "gem", + "ghost", + "heart", + "leaf", + "moon", + "mushroom", + "planet", + "star", + "sun" + ], + "badges": { + "ACAI_POWERED": [], + "ARBITRUM": [], + "ARBIVERSE_DEVCONNECT_BA_2025": [ + "crystal", + "hex", + "cheer" + ], + "BETA_TESTER": [ + "flask", + "bubble", + "wink" + ], + "BIGGEST_REQUEST_POT": [], + "BIG_SPENDER_5K": [], + "BUG_WHISPERER": [ + "beetle", + "shell", + "peek" + ], + "CARD_ALPHA": [ + "card", + "swipe", + "tape" + ], + "CARD_CLOSED_BETA": [], + "CARD_FIRST_SWIPE": [ + "card", + "chip", + "wink" + ], + "CARD_PIONEER": [], + "CARD_SPENT_1K": [ + "stack", + "note", + "cheer" + ], + "CERTIFIED_YAPPER": [], + "DEVCONNECT_BA_2025": [ + "sun", + "rise", + "wink" + ], + "DOUBLE_DIGITS": [], + "DUNBAR": [], + "ENS": [], + "ETHFLORIPA_HUB": [ + "eth", + "island", + "palm" + ], + "EVENT_ALUMNI": [ + "cap", + "board", + "wink" + ], + "FESTA_JUNINA_2026": [], + "FIRST_CRUMB": [], + "FIRST_INVITE": [], + "FOUNDER_HOUSE": [], + "FOUNDING_PIONEER": [], + "GIGA_YAPPER": [], + "INFLUENCER_25": [], + "IRL_NOMADS": [ + "pack", + "roll", + "pocket" + ], + "MANICERO": [], + "MEGA_INFLUENCER": [], + "MINI_INFLUENCER": [], + "MOST_INVITES": [], + "MOST_PAYMENTS_DEVCON": [], + "MOST_RESTAURANTS_DEVCON": [], + "NAIJA": [ + "flag", + "wave", + "wink" + ], + "NITA": [ + "letter", + "sparkle", + "wink" + ], + "NOT_SO_SHHHH": [], + "OFFRAMP_USER": [ + "bolt", + "spark", + "wink" + ], + "OG_2025_10_12": [ + "coin", + "link", + "shades" + ], + "PEANUT_SHAPER": [], + "PRODUCT_HUNT": [], + "PSYOPS_DIVISION": [], + "SECOND_INVITE": [], + "SEEDLING_DEVCONNECT_BA_2025": [], + "SHHHHH": [ + "lips", + "shush", + "wink" + ], + "SPLITTER": [], + "SUPPORT_SURVIVOR": [ + "bandage", + "headset", + "beer" + ], + "SURF_UP": [], + "TERERE": [], + "THIRD_INVITE": [], + "TOKEN_NATION_SP_2026": [], + "TOUCHED_GRASS": [], + "TRON": [], + "VERIFIED": [], + "WAITLIST_SKIP": [ + "key", + "keyhole", + "wink" + ] + } + } } From 10f29aea39f4b00fd9cada4573521d7e38195ab7 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 2 Sep 2026 23:35:29 +0100 Subject: [PATCH 10/17] feat(avatars): one row of five basics, the dice redeals it (TASK-22142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty basics at once was a wall. The picker now offers five — the current pick if it is a basic plus random fill — and the dice rerolls that row and never the pick, Split's semantics: the dice changes what is offered, not who you are. Dice, not die, everywhere users read it. --- src/components/Avatar/AvatarPicker.tsx | 128 ++++++++++-------- .../Avatar/__tests__/AvatarPicker.test.tsx | 59 +++++--- 2 files changed, 108 insertions(+), 79 deletions(-) diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index 0ff5d0cca1..c5c3c55de6 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, type KeyboardEvent } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import { updateUserById } from '@/app/actions/users' import { Button } from '@/components/0_Bruddle/Button' @@ -8,7 +8,8 @@ import { useToast } from '@/components/0_Bruddle/Toast' import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' import { useAuth } from '@/context/authContext' import { twMerge } from '@/utils/tw' -import { avatarPool, badgeAvatarKeys, basicAvatarKeys } from './avatar.utils' +import { badgeAvatarKeys, offerBasics } from './avatar.utils' +import { roveAvatarTiles } from './avatarPicker.utils' import { UserAvatar } from './UserAvatar' interface AvatarPickerProps { @@ -16,23 +17,10 @@ interface AvatarPickerProps { onOpenChange: (open: boolean) => void } -const COLUMNS = 5 - -// one tab stop per group, arrows move, wrapping (radiogroup convention) -function rove(event: KeyboardEvent) { - const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLUMNS, ArrowUp: -COLUMNS }[event.key] - if (!step) return - const radios = Array.from(event.currentTarget.querySelectorAll('[role="radio"]')) - const index = radios.indexOf(document.activeElement as HTMLButtonElement) - if (index < 0) return - event.preventDefault() - radios[(index + step + radios.length) % radios.length].focus() -} - /** * The profile avatar picker (TASK-22142): what the user's badges unlocked, - * then the basics everyone has. A tap saves at once; the die randomizes - * across the whole pool, free forever; "use my initial" clears the pick. + * then one row of the basics everyone has. A tap saves at once; the dice + * rerolls the offered row and never the pick; "use my initial" clears it. * The API validates the pick against the same pool, so a locked key never * lands even if the manifest and the catalog drift. */ @@ -45,58 +33,82 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { const userId = user?.user.userId const username = user?.user.username ?? undefined const saved = user?.user.avatarKey ?? null - const held = (user?.user.badges ?? []).map((badge) => badge.code) + const badges = user?.user.badges ?? [] + const held = badges.map((badge) => badge.code) + const badgeName = Object.fromEntries(badges.map((badge) => [badge.code, badge.name])) const unlocked = badgeAvatarKeys(held) - const basics = basicAvatarKeys() - // optimistic: the tile and the slot behind the drawer move on tap; a - // failed save snaps back and says so - const [pick, setPick] = useState(saved) - useEffect(() => setPick(saved), [saved]) + // The tile moves on tap; the slot behind the drawer moves after fetchUser + // lands. `pending` overrides `saved` while writes are in flight: last + // write wins, one refetch per burst, and a failed save snaps back. + const [pending, setPending] = useState(undefined) + const inFlight = useRef(0) + const pick = pending === undefined ? saved : pending const save = async (key: string | null) => { if (!userId) return - const previous = pick - setPick(key) + setPending(key) + inFlight.current += 1 const { error } = await updateUserById({ userId, avatarKey: key }) + inFlight.current -= 1 if (error) { - setPick(previous) toast({ type: 'error', message: t('saveFailed') }) + setPending(undefined) return } - await fetchUser() + if (inFlight.current === 0) { + await fetchUser() + setPending(undefined) + } } - const roll = () => { - const pool = avatarPool(held).filter((key) => key !== pick) - if (pool.length === 0) return - void save(pool[Math.floor(Math.random() * pool.length)]) + // the offered row of five basics: dealt on open, redealt by the dice + const [offer, setOffer] = useState([]) + useEffect(() => { + if (open) setOffer(offerBasics(saved)) + // deal once per open; the pick joins the row by being picked from it + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + const rollDice = () => setOffer(offerBasics(pick)) + + // human labels: "Bug Whisperer · beetle" for a badge avatar, the slug for a basic + const label = (key: string) => { + const [kind, code, slug] = key.split('.') + return kind === 'badge' ? `${badgeName[code] ?? code} · ${slug}` : code } - const tiles = (keys: string[], label: string) => ( -
- {keys.map((key, index) => { - const checked = key === pick - return ( - - ) - })} -
- ) + const tiles = (keys: string[], groupLabel: string) => { + const focusIndex = Math.max(0, keys.indexOf(pick ?? '')) + return ( +
+ {keys.map((key, index) => { + const checked = key === pick + return ( + + ) + })} +
+ ) + } return ( @@ -121,11 +133,11 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
{t('basics')}
- {tiles(basics, t('basics'))} + {tiles(offer, t('basics'))}
- diff --git a/src/features/home/views/__tests__/HomeTopNav.test.tsx b/src/features/home/views/__tests__/HomeTopNav.test.tsx index 24c7a17ffc..e1a79163c4 100644 --- a/src/features/home/views/__tests__/HomeTopNav.test.tsx +++ b/src/features/home/views/__tests__/HomeTopNav.test.tsx @@ -11,10 +11,10 @@ jest.mock('next/image', () => ({ })) describe('HomeTopNav', () => { - it('shows the first letter of the username — not two-letter initials, not a generated face', () => { + it('shows the first letter as sticker art — not two-letter initials, not a generated face', () => { const { container } = renderWithIntl() - expect(container.querySelector('a[href="/profile"]')).toHaveTextContent(/^T$/) + expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/letter/t.webp') expect(screen.queryByText(/^TE$/i)).not.toBeInTheDocument() expect(container.querySelector('a[href="/profile"] svg')).not.toBeInTheDocument() }) @@ -24,7 +24,7 @@ describe('HomeTopNav', () => { ) - expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/basic/frog.svg') + expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/basic/frog.webp') expect(container.querySelector('a[href="/profile"]')).not.toHaveTextContent('T') }) From 7a9778d96524665c174ab7da662e0d80a8c8589c Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 13:20:26 +0100 Subject: [PATCH 15/17] fix(avatars): serialize saves, seed the home chip from the username, demo fetch reads the demo profile (Chip, #2929) Saves now go one at a time, always the latest tap next, with one refetch in a finally, so the server can never commit an older key last and a thrown save cannot wedge the picker. The home chip's letter and palette seed from the username like the profile header, not the display name. In demo mode fetchUser reads the demo /users/me handler, so a pick survives the refetch that follows it. --- src/components/Avatar/AvatarPicker.tsx | 45 +++++--- .../Avatar/__tests__/AvatarPicker.test.tsx | 103 +++++++++++------- src/features/home/HomePage.tsx | 3 +- .../home/__tests__/useHomeFlow.test.ts | 16 +-- src/features/home/useHomeFlow.ts | 8 +- src/features/home/views/HomeTopNav.tsx | 13 ++- .../home/views/__tests__/HomeTopNav.test.tsx | 4 +- src/hooks/query/__tests__/user.test.tsx | 47 ++++++++ src/hooks/query/user.ts | 13 ++- 9 files changed, 166 insertions(+), 86 deletions(-) diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index 87e4565934..97ba071b4c 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -39,27 +39,42 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { const unlocked = badgeAvatarKeys(held) // The tile moves on tap; the slot behind the drawer moves after fetchUser - // lands. `pending` overrides `saved` while writes are in flight. One - // refetch per burst, when the last request lands, and the server's - // answer wins: a failed older request cannot roll back a newer success, - // and a lone failure snaps back to what is saved. + // lands. `pending` overrides `saved` while a burst drains. Saves are + // SERIALIZED: one POST at a time, always the latest tap next, so the + // server can never commit an older key last. One refetch per burst, in + // a finally, so a thrown save cannot wedge the picker. const [pending, setPending] = useState(undefined) - const inFlight = useRef(0) + const wanted = useRef(undefined) + const draining = useRef(false) const pick = pending === undefined ? saved : pending - const save = async (key: string | null) => { - if (!userId) return - setPending(key) - inFlight.current += 1 - const { error } = await updateUserById({ userId, avatarKey: key }) - inFlight.current -= 1 - if (error) toast({ type: 'error', message: t('saveFailed') }) - if (inFlight.current === 0) { + const drain = async () => { + draining.current = true + try { + while (wanted.current !== undefined) { + const key = wanted.current + wanted.current = undefined + try { + const { error } = await updateUserById({ userId, avatarKey: key }) + if (error) toast({ type: 'error', message: t('saveFailed') }) + } catch { + toast({ type: 'error', message: t('saveFailed') }) + } + } await fetchUser() + } finally { + draining.current = false setPending(undefined) } } + const save = (key: string | null) => { + if (!userId) return + setPending(key) + wanted.current = key + if (!draining.current) void drain() + } + // the offered row of five basics: dealt on open, redealt by the dice const [offer, setOffer] = useState([]) useEffect(() => { @@ -94,7 +109,7 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { aria-checked={checked} aria-label={label(key)} tabIndex={index === focusIndex ? 0 : -1} - onClick={() => void save(key)} + onClick={() => save(key)} className={twMerge( 'flex min-h-11 items-center justify-center rounded-sm border border-border-disabled bg-background-default p-1 focus-visible:outline-[3px] focus-visible:outline-action-focus', checked && 'border-2 border-border-default shadow-4' @@ -140,7 +155,7 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { -
diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx index 1d6a97b9bf..0f2f1f7f99 100644 --- a/src/components/Avatar/__tests__/AvatarPicker.test.tsx +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -33,6 +33,36 @@ let mockUser: { jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: mockUser, fetchUser: mockFetchUser }) })) const radio = (key: string) => screen.getByRole('radio', { name: key }) +const A = 'Bug Whisperer · beetle' +const B = 'Bug Whisperer · shell' +const KEY_A = 'badge.BUG_WHISPERER.beetle' +const KEY_B = 'badge.BUG_WHISPERER.shell' + +// A server model: every POST is recorded in order and settled by hand, in any +// order; the last write the server COMMITS is what the refetch hands back. +type Settle = (result: { data?: object; error?: string }) => void +function fakeServer() { + const posts: { key: string | null; resolve: Settle; reject: (e: Error) => void }[] = [] + let committed: string | null = null + mockUpdateUserById.mockImplementation( + ({ avatarKey }: { avatarKey: string | null }) => + new Promise((resolve, reject) => posts.push({ key: avatarKey, resolve, reject })) + ) + mockFetchUser.mockImplementation(async () => { + mockUser.user.avatarKey = committed + return null + }) + return { + posts, + committed: () => committed, + settle: (i: number, result: { data?: object; error?: string } = { data: {} }) => + act(async () => { + if (!result.error) committed = posts[i].key + posts[i].resolve(result) + }), + reject: (i: number) => act(async () => posts[i].reject(new Error('network'))), + } +} beforeEach(() => { jest.clearAllMocks() @@ -54,7 +84,7 @@ describe('AvatarPicker', () => { expect(screen.getAllByRole('radio')).toHaveLength(8) // human labels, not keys: badge name + slug, or the slug alone - expect(radio('Bug Whisperer · beetle')).toBeInTheDocument() + expect(radio(A)).toBeInTheDocument() expect(screen.getByRole('radiogroup', { name: 'Basics' }).querySelectorAll('[role="radio"]')).toHaveLength(5) expect(screen.queryByRole('radio', { name: /Offramp/ })).not.toBeInTheDocument() expect(screen.getByRole('radiogroup', { name: 'From your badges' })).toBeInTheDocument() @@ -71,77 +101,72 @@ describe('AvatarPicker', () => { it('saves a tap at once and refreshes the user', async () => { renderWithIntl() - fireEvent.click(radio('Bug Whisperer · peek')) + fireEvent.click(radio(B)) - expect(radio('Bug Whisperer · peek')).toHaveAttribute('aria-checked', 'true') - expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: 'badge.BUG_WHISPERER.peek' }) - await waitFor(() => expect(mockFetchUser).toHaveBeenCalled()) + expect(radio(B)).toHaveAttribute('aria-checked', 'true') + expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: KEY_B }) + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) }) it('snaps back and says so when the save fails', async () => { - mockUser.user.avatarKey = 'basic.sun' + mockUser.user.avatarKey = KEY_A mockUpdateUserById.mockResolvedValue({ error: 'Avatar not unlocked' }) renderWithIntl() - fireEvent.click(radio('Bug Whisperer · peek')) + fireEvent.click(radio(B)) - await waitFor(() => expect(radio('sun')).toHaveAttribute('aria-checked', 'true')) + await waitFor(() => expect(radio(A)).toHaveAttribute('aria-checked', 'true')) expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) // the server is the truth after a burst, failed or not expect(mockFetchUser).toHaveBeenCalledTimes(1) }) - // Chip (#2929): two taps whose responses arrive out of order - const deferred = () => { - let resolve!: (value: { data?: object; error?: string }) => void - const promise = new Promise<{ data?: object; error?: string }>((r) => (resolve = r)) - return { promise, resolve } - } - const A = 'Bug Whisperer · beetle' - const B = 'Bug Whisperer · shell' - - it('the last tap wins when the first response arrives after the second', async () => { - const a = deferred() - const b = deferred() - mockUpdateUserById.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise) - // the server holds the last write; the refetch reads it back - mockFetchUser.mockImplementation(async () => { - mockUser.user.avatarKey = 'badge.BUG_WHISPERER.shell' - return null - }) + // Chip (#2929): saves are serialized, so the server can never commit an + // older key last, whatever order the responses come back in. + it('sends one save at a time, always the latest tap next, and the server ends on the last tap', async () => { + const server = fakeServer() renderWithIntl() fireEvent.click(radio(A)) fireEvent.click(radio(B)) - await act(async () => b.resolve({ data: {} })) + + // (a) the second POST is not sent before the first settles + expect(server.posts.map((p) => p.key)).toEqual([KEY_A]) expect(radio(B)).toHaveAttribute('aria-checked', 'true') + + await server.settle(0) + expect(server.posts.map((p) => p.key)).toEqual([KEY_A, KEY_B]) expect(mockFetchUser).not.toHaveBeenCalled() - await act(async () => a.resolve({ data: {} })) + await server.settle(1) + // (b) the server's last write is the last tap, refetched once await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + expect(server.committed()).toBe(KEY_B) expect(radio(B)).toHaveAttribute('aria-checked', 'true') expect(radio(A)).toHaveAttribute('aria-checked', 'false') }) - it('a failed older request does not roll back a newer success', async () => { - const a = deferred() - const b = deferred() - mockUpdateUserById.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise) - mockFetchUser.mockImplementation(async () => { - mockUser.user.avatarKey = 'badge.BUG_WHISPERER.shell' - return null - }) + it('a rejected first save still lets the second go through and clears pending', async () => { + const server = fakeServer() renderWithIntl() fireEvent.click(radio(A)) fireEvent.click(radio(B)) - await act(async () => b.resolve({ data: {} })) - await act(async () => a.resolve({ error: 'Avatar not unlocked' })) + await server.reject(0) - await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(server.posts.map((p) => p.key)).toEqual([KEY_A, KEY_B]) + + await server.settle(1) + + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + expect(server.committed()).toBe(KEY_B) expect(radio(B)).toHaveAttribute('aria-checked', 'true') + // pending is cleared: a later refetch that says otherwise wins + mockUser.user.avatarKey = KEY_A + fireEvent.click(screen.getByRole('button', { name: 'Roll the dice' })) + expect(radio(A)).toHaveAttribute('aria-checked', 'true') }) it('the dice redeals the basics row and never changes the pick', () => { diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx index 92df33a450..6d13e74ab6 100644 --- a/src/features/home/HomePage.tsx +++ b/src/features/home/HomePage.tsx @@ -27,7 +27,6 @@ export function HomePage() { const { isPageLoading, username, - avatarName, avatarKey, isActivated, activationStep, @@ -46,7 +45,7 @@ export function HomePage() { return (
- + { expect(mockDisconnect).not.toHaveBeenCalled() }) - it('derives avatarName from the showFullName preference', () => { + it('never derives an avatar name from the display name — the chip seeds from the username', () => { mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' }) - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') - - mockUser = userWith({ showFullName: false, fullName: 'Kushagra S' }) - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('kush') - - // usernameless: full name still seeds the initials - mockUser = { user: { userId: 'u1', username: null, fullName: 'Kushagra S' } } - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') - - mockUser = { user: { userId: 'u1', username: null } } - expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBeUndefined() + const flow = renderHook(() => useHomeFlow()).result.current + expect(flow.username).toBe('kush') + expect(flow).not.toHaveProperty('avatarName') }) it('passes the picked avatar through, null when there is none', () => { diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts index 4f30dce163..2223e8912f 100644 --- a/src/features/home/useHomeFlow.ts +++ b/src/features/home/useHomeFlow.ts @@ -51,17 +51,13 @@ export function useHomeFlow() { } }, [isWagmiConnected, disconnectWagmi]) - // respect the showFullName preference for the avatar initials; a - // usernameless user still gets initials from their full name (initials - // only — the preference governs showing the full name, not its initials) - const avatarName = (user?.user.showFullName && user?.user.fullName) || username || user?.user.fullName || undefined - // the picked avatar (TASK-22142); null keeps the first-letter fallback + // the picked avatar (TASK-22142); null keeps the first-letter fallback, + // which the top nav seeds from the username, never the display name const avatarKey = user?.user.avatarKey ?? null return { isPageLoading: isFetchingUser && !username, username, - avatarName, avatarKey, isActivated, activationStep, diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx index 63c1241709..193c56c869 100644 --- a/src/features/home/views/HomeTopNav.tsx +++ b/src/features/home/views/HomeTopNav.tsx @@ -8,7 +8,7 @@ import { useAppTranslations } from '@/i18n/app/useAppTranslations' import Link from 'next/link' interface HomeTopNavProps { - avatarName?: string + username?: string avatarKey?: string | null showRewards: boolean } @@ -18,7 +18,7 @@ interface HomeTopNavProps { * top-left linking to /profile (Vlad follow-up: one size down from 48), * rewards link top-right. The link keeps a 44px hit area via after: inset. */ -export function HomeTopNav({ avatarName, avatarKey, showRewards }: HomeTopNavProps) { +export function HomeTopNav({ username, avatarKey, showRewards }: HomeTopNavProps) { const t = useAppTranslations('home') const { triggerHaptic } = useAppHaptic() @@ -32,10 +32,11 @@ export function HomeTopNav({ avatarName, avatarKey, showRewards }: HomeTopNavPro aria-label={t('openProfile')} > {/* Own identity: the picked avatar (TASK-22142), or the first - letter of the name, here and on the profile header. A user - with no name string at all still gets an avatar-toned circle - (yellow — the palette's no-name default). */} - + letter of the USERNAME — the same seed as the profile header, + so the letter and its palette never follow the display name. + No username yet still gets an avatar-toned circle (yellow — + the palette's no-name default). */} + {showRewards && ( ({ describe('HomeTopNav', () => { it('shows the first letter as sticker art — not two-letter initials, not a generated face', () => { - const { container } = renderWithIntl() + const { container } = renderWithIntl() expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/letter/t.webp') expect(screen.queryByText(/^TE$/i)).not.toBeInTheDocument() @@ -21,7 +21,7 @@ describe('HomeTopNav', () => { it('wears the picked avatar inside the profile link (TASK-22142)', () => { const { container } = renderWithIntl( - + ) expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/basic/frog.webp') diff --git a/src/hooks/query/__tests__/user.test.tsx b/src/hooks/query/__tests__/user.test.tsx index fc2135c00d..dd622ad0a0 100644 --- a/src/hooks/query/__tests__/user.test.tsx +++ b/src/hooks/query/__tests__/user.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react' import { useUserQuery } from '../user' import { apiFetch } from '@/utils/api-fetch' import { setAuthToken, clearAuthToken } from '@/utils/auth-token' +import { isDemoMode } from '@/utils/demo' jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) jest.mock('@/utils/auth-token', () => ({ @@ -20,6 +21,9 @@ jest.mock('@/redux/hooks', () => ({ useUserStore: () => ({ user: null }), })) jest.mock('posthog-js', () => ({ default: { capture: jest.fn() }, capture: jest.fn() })) +jest.mock('@/utils/demo', () => ({ isDemoMode: jest.fn(() => false) })) +// demo-api → demo → general.utils → app/actions/clients starts viem timers that keep the worker alive +jest.mock('@/app/actions/clients', () => ({})) const mockApiFetch = apiFetch as jest.MockedFunction const mockSetAuthToken = setAuthToken as jest.MockedFunction @@ -143,3 +147,46 @@ describe('useUserQuery — JWT sliding refresh', () => { expect(mockClearAuthToken).not.toHaveBeenCalled() }) }) + +describe('useUserQuery — demo mode', () => { + // jsdom strips the WebView's global Response; the demo routes build one. + // Only what fetchUser reads back: ok, status, json(). + class TestResponse { + status: number + constructor( + private body: string, + init?: { status?: number } + ) { + this.status = init?.status ?? 200 + } + get ok() { + return this.status >= 200 && this.status < 300 + } + json() { + return Promise.resolve(JSON.parse(this.body)) + } + } + const originalResponse = global.Response + beforeAll(() => { + global.Response = TestResponse as unknown as typeof Response + }) + afterAll(() => { + global.Response = originalResponse + ;(isDemoMode as jest.Mock).mockReturnValue(false) + }) + + it('keeps an avatar picked through the demo routes across a refetch (TASK-22142)', async () => { + ;(isDemoMode as jest.Mock).mockReturnValue(true) + const { demoRespond } = await import('@/utils/demo-api') + await demoRespond('/update-user', { + method: 'POST', + body: JSON.stringify({ username: 'demo', avatarKey: 'basic.frog' }), + }) + + const { result } = renderHook(() => useUserQuery(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + // the real fetchUser path, through the mutable demo profile, not the static constant + expect(result.current.data?.user.avatarKey).toBe('basic.frog') + }) +}) diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts index 0c782979ad..747dec15dc 100644 --- a/src/hooks/query/user.ts +++ b/src/hooks/query/user.ts @@ -11,7 +11,6 @@ import { apiFetch } from '@/utils/api-fetch' import { clearAuthToken, getAuthToken, getClearEpoch, setAuthToken } from '@/utils/auth-token' import { isDemoMode } from '@/utils/demo' import { isNativeBridge } from '@/utils/capacitor' -import { DEMO_USER } from '@/constants/demo-data' // custom error class for backend errors (5xx) that should trigger retry export class BackendError extends Error { @@ -29,10 +28,16 @@ export const useUserQuery = (dependsOn: boolean = true) => { const { user: authUser } = useUserStore() const fetchUser = async (): Promise => { - // Demo mode: no backend/JWT/passkey — return the synthetic user. + // Demo mode: no backend/JWT/passkey — the synthetic user, read through + // the demo /users/me handler so state the demo routes mutate (the + // picked avatar, the celebration stamp) survives a refetch. Lazy + // import keeps the demo module out of the main bundle (api-fetch + // does the same). if (isDemoMode()) { - dispatch(userActions.setUser(DEMO_USER)) - return DEMO_USER + const { demoRespond } = await import('@/utils/demo-api') + const payload: IUserProfile = await (await demoRespond('/users/me')).json() + dispatch(userActions.setUser(payload)) + return payload } const epochAtRequest = getClearEpoch() From e7fbf491967989f3dcfabc889ee7285ba5054386 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 14:02:22 +0100 Subject: [PATCH 16/17] fix(avatars): stickers sit plain on the page, no circle and no palette ring Art direction, 2026-09-03: a sticker carries its own colours and edge, so UserAvatar renders the picked or letter sticker at full size with nothing behind it. The palette-triple hash and its classes go with the circle. --- src/components/Avatar/UserAvatar.tsx | 26 +++++++------------ .../Avatar/__tests__/avatar.utils.test.ts | 17 +----------- src/components/Avatar/avatar.utils.ts | 19 -------------- 3 files changed, 10 insertions(+), 52 deletions(-) diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx index dca58087ec..648c3c4655 100644 --- a/src/components/Avatar/UserAvatar.tsx +++ b/src/components/Avatar/UserAvatar.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl' import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import { AVATAR_SIZE_CLASSES, type AvatarSize } from '@/components/Profile/avatar-size.consts' import { twMerge } from '@/utils/tw' -import { avatarPaletteClass, avatarSrc, letterAvatarSrc } from './avatar.utils' +import { avatarSrc, letterAvatarSrc } from './avatar.utils' interface UserAvatarProps { /** Display name: the source of the day-0 letter sticker and of the label. */ @@ -16,24 +16,17 @@ interface UserAvatarProps { } /** - * The user's own avatar (TASK-22142): the picked character on its palette - * triple. Without a pick, the first letter of the name as a sticker, keyed on - * the name so the colour is stable before anything is picked. A name that does - * not start with a-z falls through to the first-letter avatar, which stays the - * one place that renders a bare initial. + * The user's own avatar (TASK-22142): the picked sticker, plain on the page — + * a sticker carries its own colours and edge, so no circle and no palette ring + * (art direction, 2026-09-03). Without a pick, the first letter of the name as + * a sticker. A name that does not start with a-z falls through to the + * first-letter avatar, which stays the one place that renders a bare initial. */ export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: UserAvatarProps) { const t = useTranslations('common') const picked = avatarKey ? avatarSrc(avatarKey) : null const letter = picked ? null : letterAvatarSrc(name) - // the palette is keyed on whatever identifies the art: the pick, or the - // name behind the letter - const art = - picked && avatarKey - ? { src: picked, paletteKey: avatarKey } - : letter && name - ? { src: letter, paletteKey: name } - : null + const art = picked ?? letter if (!art) { return name ? ( @@ -57,13 +50,12 @@ export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: ? { role: 'img', 'aria-label': t('userAvatarAlt', { username: name }) } : { 'aria-hidden': true })} className={twMerge( - 'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border', - avatarPaletteClass(art.paletteKey), + 'inline-flex shrink-0 items-center justify-center', AVATAR_SIZE_CLASSES[size], className )} > - + ) } diff --git a/src/components/Avatar/__tests__/avatar.utils.test.ts b/src/components/Avatar/__tests__/avatar.utils.test.ts index c283dc118f..e299971a25 100644 --- a/src/components/Avatar/__tests__/avatar.utils.test.ts +++ b/src/components/Avatar/__tests__/avatar.utils.test.ts @@ -1,15 +1,7 @@ import { existsSync } from 'fs' import { join } from 'path' import badgeAssets from '@/types/badge-assets.json' -import { - avatarPaletteClass, - avatarPool, - avatarSrc, - badgeAvatarKeys, - basicAvatarKeys, - letterAvatarSrc, - offerBasics, -} from '../avatar.utils' +import { avatarPool, avatarSrc, badgeAvatarKeys, basicAvatarKeys, letterAvatarSrc, offerBasics } from '../avatar.utils' describe('avatar catalog', () => { // the manifest is the API's contract: every slug it names must be real art @@ -89,11 +81,4 @@ describe('avatar catalog', () => { expect(offerBasics('badge.BUG_WHISPERER.beetle', 5, seeded(3))).toHaveLength(5) expect(offerBasics(null, 5, seeded(4))).toHaveLength(5) }) - - it('keeps a stable palette per key, from the seven avatar triples', () => { - expect(avatarPaletteClass('basic.apple')).toBe(avatarPaletteClass('basic.apple')) - expect(avatarPaletteClass('basic.apple')).toMatch( - /^bg-avatar-(pink|yellow|purple|blue|red|orange|green) border-avatar-\1-border$/ - ) - }) }) diff --git a/src/components/Avatar/avatar.utils.ts b/src/components/Avatar/avatar.utils.ts index 9be8565244..fb1fba7893 100644 --- a/src/components/Avatar/avatar.utils.ts +++ b/src/components/Avatar/avatar.utils.ts @@ -56,25 +56,6 @@ export function avatarSrc(key: string | null | undefined): string | null { return null } -// the seven avatar triples (board 17802:61529) as full class literals so the -// tailwind scanner emits them; fill and border travel together -const PALETTE_CLASSES = [ - 'bg-avatar-pink border-avatar-pink-border', - 'bg-avatar-yellow border-avatar-yellow-border', - 'bg-avatar-purple border-avatar-purple-border', - 'bg-avatar-blue border-avatar-blue-border', - 'bg-avatar-red border-avatar-red-border', - 'bg-avatar-orange border-avatar-orange-border', - 'bg-avatar-green border-avatar-green-border', -] as const - -/** Fill + border classes of one avatar triple, stable per key. */ -export function avatarPaletteClass(key: string): string { - let hash = 5381 - for (let i = 0; i < key.length; i++) hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0 - return PALETTE_CLASSES[hash % PALETTE_CLASSES.length] -} - /** * Sticker art for the first letter of a name, or null when the first character * is not a-z. The letter set is art in `public/avatars/letter/`, not a manifest From 427292f585c37703aedae7a4b8a368d805a8a110 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 3 Sep 2026 14:16:44 +0100 Subject: [PATCH 17/17] fix(avatars): a tap during the closing refetch drains too Chip: after the last POST the drain awaited fetchUser with draining still set, so a tap in that window queued on wanted and was cleared by the finally without ever being sent. The loop now repeats until nothing is queued after the refetch; test covers tap A, settle A, tap B mid-refetch. --- src/components/Avatar/AvatarPicker.tsx | 24 ++++++++------ .../Avatar/__tests__/AvatarPicker.test.tsx | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index 97ba071b4c..8a6256c08f 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -51,17 +51,21 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { const drain = async () => { draining.current = true try { - while (wanted.current !== undefined) { - const key = wanted.current - wanted.current = undefined - try { - const { error } = await updateUserById({ userId, avatarKey: key }) - if (error) toast({ type: 'error', message: t('saveFailed') }) - } catch { - toast({ type: 'error', message: t('saveFailed') }) + // a tap that lands while the refetch is in flight queues on + // `wanted`; drain again rather than drop it with the finally + do { + while (wanted.current !== undefined) { + const key = wanted.current + wanted.current = undefined + try { + const { error } = await updateUserById({ userId, avatarKey: key }) + if (error) toast({ type: 'error', message: t('saveFailed') }) + } catch { + toast({ type: 'error', message: t('saveFailed') }) + } } - } - await fetchUser() + await fetchUser() + } while (wanted.current !== undefined) } finally { draining.current = false setPending(undefined) diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx index 0f2f1f7f99..992af7c337 100644 --- a/src/components/Avatar/__tests__/AvatarPicker.test.tsx +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -147,6 +147,38 @@ describe('AvatarPicker', () => { expect(radio(A)).toHaveAttribute('aria-checked', 'false') }) + it('a tap during the closing refetch is sent, not dropped', async () => { + const server = fakeServer() + // hold the refetch open so a tap can land while it is in flight + let releaseFetch: () => void = () => {} + mockFetchUser.mockImplementation( + () => + new Promise((resolve) => { + releaseFetch = () => { + mockUser.user.avatarKey = server.committed() + resolve(null) + } + }) + ) + renderWithIntl() + + fireEvent.click(radio(A)) + await server.settle(0) + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + + fireEvent.click(radio(B)) + expect(server.posts.map((p) => p.key)).toEqual([KEY_A]) + await act(async () => releaseFetch()) + + // the queued tap drains after the refetch: B is posted, committed, refetched + await waitFor(() => expect(server.posts.map((p) => p.key)).toEqual([KEY_A, KEY_B])) + await server.settle(1) + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(2)) + await act(async () => releaseFetch()) + expect(server.committed()).toBe(KEY_B) + expect(radio(B)).toHaveAttribute('aria-checked', 'true') + }) + it('a rejected first save still lets the second go through and clears pending', async () => { const server = fakeServer() renderWithIntl()