{t('noBadgeAvatars')}
+ )} +.`. 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) => 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[] => [
+ ...basicAvatarKeys(),
+ ...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]}.webp`
+ if (kind === 'badge' && rest.length === 2 && slugsOf(rest[0]).includes(rest[1])) {
+ return `/avatars/badge/${rest[0]}/${rest[1]}.webp`
+ }
+ return null
+}
+
+/**
+ * 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
+ * entry: it is never a pick, only the day-0 look of a user who has not picked.
+ */
+export function letterAvatarSrc(name: string | null | undefined): string | null {
+ const ch = name?.trim().charAt(0).toLowerCase()
+ return ch && ch >= 'a' && ch <= 'z' ? `/avatars/letter/${ch}.webp` : 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 7ff6afedb4..4b12a36146 100644
--- a/src/components/Badges/BadgeEarnToast.tsx
+++ b/src/components/Badges/BadgeEarnToast.tsx
@@ -27,6 +27,8 @@ 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'
+import { AVATAR_PICKER_PATH } from '@/components/Avatar/avatar.consts'
const HOME_PATH = '/home'
@@ -66,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,
@@ -81,6 +83,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
+ // 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)
+ 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',
@@ -90,19 +103,27 @@ export default function BadgeEarnToast() {
// priority icon by construction (ToastStack), no hideIcon needed.
className: 'border border-action-secondary bg-background-default',
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..57aaefede1 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,11 +91,11 @@ 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 })
+ 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()
@@ -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/AvatarWithBadge.tsx b/src/components/Profile/AvatarWithBadge.tsx
index 5669aa9146..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.
@@ -19,6 +20,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,21 +48,9 @@ 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
- // 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,
@@ -66,16 +60,20 @@ 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 (
-
+
= ({
// 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 5aca737f08..020c8f4ee3 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'
@@ -19,6 +21,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 = ({
@@ -28,8 +32,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
@@ -37,6 +43,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.
@@ -61,9 +68,25 @@ const ProfileHeader: React.FC = ({
return (
<>
- {/* ds Avatar only (ruled 2026-09-02, TASK-22121): the
- generated dot-face experiment is reverted. */}
-
+ {/* 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 ? (
+
+ ) : (
+ ownAvatar
+ )
+ ) : (
+
+ )}
{/* Name */}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 0f9aabb334..3bdb29997a 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -19,6 +19,9 @@ 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 { useQueryState } from 'nuqs'
+import { AvatarPicker } from '@/components/Avatar/AvatarPicker'
+import { AVATAR_PICKER_PARAM, avatarPickerParser } from '@/components/Avatar/avatar.consts'
import { useOtaUpdate } from '@/context/OtaUpdateContext'
import OtaUpdateModal from './components/OtaUpdateModal'
import { openStore } from '@/utils/migration.utils'
@@ -28,6 +31,8 @@ import { isIOSNative } from '@/utils/capacitor'
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(AVATAR_PICKER_PARAM, avatarPickerParser)
const router = useRouter()
const onBack = useSafeBack('/home')
// Profile "verified" reflects identity verification only (the human was ID-verified) — NOT
@@ -70,7 +75,13 @@ export const Profile = () => {
copy-username icon it used to lean on was removed
(TASK-22121 #24), so suppressing the pill here left the
page with no way to share at all */}
-
+ setAvatarPickerOpen(true)}
+ />
+
{/* IA from #2834: identity/products first, then social +
account, then app settings. Payment limits moved inline
diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx
index 3db0caf5b3..990d862e7f 100644
--- a/src/components/UserHeader/index.tsx
+++ b/src/components/UserHeader/index.tsx
@@ -1,6 +1,6 @@
'use client'
-import AvatarWithBadge from '@/components/Profile/AvatarWithBadge'
+import { UserAvatar } from '@/components/Avatar/UserAvatar'
import Link from 'next/link'
import { Icon } from '../Global/Icons/Icon'
import { twMerge } from '@/utils/tw'
@@ -18,6 +18,7 @@ interface UserHeaderProps {
}
export const UserHeader = ({ username }: UserHeaderProps) => {
+ const { user: authenticatedUser } = useAuth()
return (
diff --git a/src/dev/fixtures/__tests__/fixtures.test.ts b/src/dev/fixtures/__tests__/fixtures.test.ts
index 7dab12db1c..feea062813 100644
--- a/src/dev/fixtures/__tests__/fixtures.test.ts
+++ b/src/dev/fixtures/__tests__/fixtures.test.ts
@@ -9,8 +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
- // a fixture may pin url state (?method=bank) — the guard checks the path
- for (const segment of route.split('?')[0].split('/').filter(Boolean)) {
+ // a fixture may open a route with its own query (nuqs URL state)
+ 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 2760bc73ea..8bf97af51c 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.
@@ -333,6 +334,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: AVATAR_PICKER_PATH,
+ 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.
// ---------------------------------------------------------------------
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', () => {
+ 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')
+ const flow = renderHook(() => useHomeFlow()).result.current
+ expect(flow.username).toBe('kush')
+ expect(flow).not.toHaveProperty('avatarName')
+ })
- // 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')
+ 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 = { user: { userId: 'u1', username: null } }
- expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBeUndefined()
+ mockUser = userWith({})
+ expect(renderHook(() => useHomeFlow()).result.current.avatarKey).toBeNull()
})
})
diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts
index 128727bb61..2223e8912f 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); 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,
dismissCardStep,
diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx
index e133b3a705..193c56c869 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
+ username?: 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({ username, avatarKey, showRewards }: HomeTopNavProps) {
const t = useAppTranslations('home')
const { triggerHaptic } = useAppHaptic()
@@ -30,21 +31,12 @@ export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) {
className="relative block after:absolute after:-inset-1.5"
aria-label={t('openProfile')}
>
- {/* ds Avatar only (ruled 2026-09-02, TASK-22121): the generated
- dot-face experiment is reverted. A user with no name string
- still gets an avatar-toned circle (yellow — the palette's
- no-name default). */}
-
+ {/* Own identity: the picked avatar (TASK-22142), or the first
+ 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 && (
({ 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 ds initials avatar — the dot-face experiment is reverted', () => {
- const { container } = renderWithIntl( )
+ it('shows the first letter as sticker art — not two-letter initials, not a generated face', () => {
+ const { container } = renderWithIntl( )
- // AvatarWithBadge renders the initials; the dot face drew an svg
- expect(screen.getByText(/^TE$/i)).toBeInTheDocument()
+ 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()
})
+ 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')
+ 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( )
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()
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index ad13b8079d..0706845baa 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -56,7 +56,8 @@
"unknown": "Unknown"
},
"exchangeRate": "Exchange rate",
- "slideToProceed": "Slide to Proceed"
+ "slideToProceed": "Slide to Proceed",
+ "userAvatarAlt": "Avatar for {username}"
},
"navigation": {
"home": "Home",
@@ -2495,7 +2496,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?",
@@ -3617,5 +3620,17 @@
"android": "Update Android System WebView"
},
"continueAnyway": "Continue anyway"
+ },
+ "avatar": {
+ "title": "Your avatar",
+ "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.",
+ "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 e636645144..8f8dabc66a 100644
--- a/src/i18n/app/messages/en.marketing.json
+++ b/src/i18n/app/messages/en.marketing.json
@@ -56,7 +56,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 bac095c775..88b70268ea 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -56,7 +56,8 @@
"unknown": "Desconocido"
},
"exchangeRate": "Tipo de cambio",
- "slideToProceed": "Desliza para continuar"
+ "slideToProceed": "Desliza para continuar",
+ "userAvatarAlt": "Avatar de {username}"
},
"navigation": {
"home": "Inicio",
@@ -2495,7 +2496,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?",
@@ -3617,5 +3620,17 @@
"android": "Actualizar Android System WebView"
},
"continueAnyway": "Continuar de todos modos"
+ },
+ "avatar": {
+ "title": "Tu avatar",
+ "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í.",
+ "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 230f6b00eb..d2d734ca5f 100644
--- a/src/i18n/app/messages/es-419.marketing.json
+++ b/src/i18n/app/messages/es-419.marketing.json
@@ -56,7 +56,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 505a84bf95..d7b46f4721 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -56,7 +56,8 @@
"unknown": "Desconhecido"
},
"exchangeRate": "Taxa de câmbio",
- "slideToProceed": "Deslize para continuar"
+ "slideToProceed": "Deslize para continuar",
+ "userAvatarAlt": "Avatar de {username}"
},
"navigation": {
"home": "Início",
@@ -2495,7 +2496,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?",
@@ -3617,5 +3620,17 @@
"android": "Atualizar o Android System WebView"
},
"continueAnyway": "Continuar mesmo assim"
+ },
+ "avatar": {
+ "title": "Seu avatar",
+ "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.",
+ "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 0fbe7b0370..549c570caf 100644
--- a/src/i18n/app/messages/pt-BR.marketing.json
+++ b/src/i18n/app/messages/pt-BR.marketing.json
@@ -56,7 +56,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/interfaces/interfaces.ts b/src/interfaces/interfaces.ts
index 2721b4640f..7487312e90 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
diff --git a/src/types/badge-assets.json b/src/types/badge-assets.json
index 4696a123fd..8af0d7fef9 100644
--- a/src/types/badge-assets.json
+++ b/src/types/badge-assets.json
@@ -54,5 +54,85 @@
"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": [],
+ "SURF_UP": [],
+ "TERERE": [],
+ "THIRD_INVITE": [],
+ "TOKEN_NATION_SP_2026": [],
+ "TOUCHED_GRASS": [],
+ "TRON": [],
+ "VERIFIED": [],
+ "WAITLIST_SKIP": ["key", "keyhole", "wink"]
+ }
}
}
diff --git a/src/utils/__tests__/demo-api.test.ts b/src/utils/__tests__/demo-api.test.ts
index 66e3a2a7bb..716aa3f39b 100644
--- a/src/utils/__tests__/demo-api.test.ts
+++ b/src/utils/__tests__/demo-api.test.ts
@@ -109,6 +109,25 @@ describe('demoRespond — routing', () => {
expect(after.data.user.activationCelebratedAt).toBeTruthy()
})
+ it('keeps the picked avatar between update-user and users/me, null clears it', async () => {
+ // a pick made through the picker (TASK-22142) must survive the refetch
+ // that follows it, or the tile snaps back to the initial in demo mode
+ expect((await body('/users/me')).data.user.avatarKey).toBeNull()
+
+ await body('/update-user', {
+ method: 'POST',
+ body: JSON.stringify({ username: 'demo', avatarKey: 'basic.frog' }),
+ })
+ expect((await body('/users/me')).data.user.avatarKey).toBe('basic.frog')
+
+ // a body without the field leaves the pick alone, as the API does
+ await body('/update-user', { method: 'POST', body: JSON.stringify({ username: 'demo', showFullName: true }) })
+ expect((await body('/users/me')).data.user.avatarKey).toBe('basic.frog')
+
+ await body('/update-user', { method: 'POST', body: JSON.stringify({ username: 'demo', avatarKey: null }) })
+ expect((await body('/users/me')).data.user.avatarKey).toBeNull()
+ })
+
it('persists the celebration stamp across cold starts via localStorage', async () => {
// in-memory-only state re-showed the modal on every demo launch; a fresh
// module registry per isolateModules block simulates the cold start
diff --git a/src/utils/demo-api.ts b/src/utils/demo-api.ts
index be3e68c096..e4e5686ead 100644
--- a/src/utils/demo-api.ts
+++ b/src/utils/demo-api.ts
@@ -45,6 +45,8 @@ function json(data: unknown, status = 200): Response {
}
type DemoRequestBody = {
+ /** profile avatar pick (TASK-22142): string sets, null clears */
+ avatarKey?: string | null
tokenAmount?: string | number
requestProps?: { tokenAmount?: string | number }
local_price?: { amount?: string | number }
@@ -365,6 +367,11 @@ const stampDemoActivationCelebrated = (): void => {
} catch {}
}
+// The picked profile avatar (TASK-22142), tab-scoped like the rest of the
+// demo state: a pick made through the picker must survive the next
+// GET /users/me or the tile snaps back. Fixtures still override on top.
+let demoAvatarKey: string | null = null
+
// ---- routes (ordered: literal paths before :param paths) ----
const ROUTES: Array<{ method: string; pattern: string; handler: Handler }> = [
@@ -374,9 +381,14 @@ const ROUTES: Array<{ method: string; pattern: string; handler: Handler }> = [
pattern: '/users/me',
handler: () => {
const celebratedAt = getDemoActivationCelebratedAt()
- return celebratedAt
- ? { ...DEMO_USER, user: { ...DEMO_USER.user, activationCelebratedAt: celebratedAt } }
- : DEMO_USER
+ return {
+ ...DEMO_USER,
+ user: {
+ ...DEMO_USER.user,
+ avatarKey: demoAvatarKey,
+ ...(celebratedAt ? { activationCelebratedAt: celebratedAt } : {}),
+ },
+ }
},
},
{
@@ -443,6 +455,8 @@ const ROUTES: Array<{ method: string; pattern: string; handler: Handler }> = [
handler: ({ options }) => {
const body = parseBody(options)
if (body.dismissActivationCelebration) stampDemoActivationCelebrated()
+ // string sets, null clears, absent leaves it alone — as the API does
+ if ('avatarKey' in body) demoAvatarKey = body.avatarKey ?? null
return demoApiUser(body.username ?? 'demo')
},
},