diff --git a/e2e/shots/fixtures.spec.ts b/e2e/shots/fixtures.spec.ts index efbc6ba122..b89cd602dc 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' @@ -129,10 +129,7 @@ for (const [name, fixture] of Object.entries(FIXTURES)) { await page.clock.setFixedTime(FROZEN_NOW) await page.addInitScript(seenOnceModals) - // a fixture route may already pin url state (?method=bank) — join accordingly - await page.goto(`${fixture.route}${fixture.route.includes('?') ? '&' : '?'}${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/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.webp b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.webp new file mode 100644 index 0000000000..ad26b2f063 Binary files /dev/null and b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/cheer.webp differ diff --git a/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.webp b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.webp new file mode 100644 index 0000000000..e1d0aff1a2 Binary files /dev/null and b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/crystal.webp differ diff --git a/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.webp b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.webp new file mode 100644 index 0000000000..c7621c62dd Binary files /dev/null and b/public/avatars/badge/ARBIVERSE_DEVCONNECT_BA_2025/hex.webp differ diff --git a/public/avatars/badge/BETA_TESTER/bubble.webp b/public/avatars/badge/BETA_TESTER/bubble.webp new file mode 100644 index 0000000000..50fb3d42c4 Binary files /dev/null and b/public/avatars/badge/BETA_TESTER/bubble.webp differ diff --git a/public/avatars/badge/BETA_TESTER/flask.webp b/public/avatars/badge/BETA_TESTER/flask.webp new file mode 100644 index 0000000000..35b523c3c0 Binary files /dev/null and b/public/avatars/badge/BETA_TESTER/flask.webp differ diff --git a/public/avatars/badge/BETA_TESTER/wink.webp b/public/avatars/badge/BETA_TESTER/wink.webp new file mode 100644 index 0000000000..ed4eb4d855 Binary files /dev/null and b/public/avatars/badge/BETA_TESTER/wink.webp differ diff --git a/public/avatars/badge/BUG_WHISPERER/beetle.webp b/public/avatars/badge/BUG_WHISPERER/beetle.webp new file mode 100644 index 0000000000..4227745b46 Binary files /dev/null and b/public/avatars/badge/BUG_WHISPERER/beetle.webp differ diff --git a/public/avatars/badge/BUG_WHISPERER/peek.webp b/public/avatars/badge/BUG_WHISPERER/peek.webp new file mode 100644 index 0000000000..97566d6ea0 Binary files /dev/null and b/public/avatars/badge/BUG_WHISPERER/peek.webp differ diff --git a/public/avatars/badge/BUG_WHISPERER/shell.webp b/public/avatars/badge/BUG_WHISPERER/shell.webp new file mode 100644 index 0000000000..0783c68a37 Binary files /dev/null and b/public/avatars/badge/BUG_WHISPERER/shell.webp differ diff --git a/public/avatars/badge/CARD_ALPHA/card.webp b/public/avatars/badge/CARD_ALPHA/card.webp new file mode 100644 index 0000000000..5bd6808164 Binary files /dev/null and b/public/avatars/badge/CARD_ALPHA/card.webp differ diff --git a/public/avatars/badge/CARD_ALPHA/swipe.webp b/public/avatars/badge/CARD_ALPHA/swipe.webp new file mode 100644 index 0000000000..dfa5331693 Binary files /dev/null and b/public/avatars/badge/CARD_ALPHA/swipe.webp differ diff --git a/public/avatars/badge/CARD_ALPHA/tape.webp b/public/avatars/badge/CARD_ALPHA/tape.webp new file mode 100644 index 0000000000..495c0efd91 Binary files /dev/null and b/public/avatars/badge/CARD_ALPHA/tape.webp differ diff --git a/public/avatars/badge/CARD_FIRST_SWIPE/card.webp b/public/avatars/badge/CARD_FIRST_SWIPE/card.webp new file mode 100644 index 0000000000..7eb1707687 Binary files /dev/null and b/public/avatars/badge/CARD_FIRST_SWIPE/card.webp differ diff --git a/public/avatars/badge/CARD_FIRST_SWIPE/chip.webp b/public/avatars/badge/CARD_FIRST_SWIPE/chip.webp new file mode 100644 index 0000000000..6bafa1e054 Binary files /dev/null and b/public/avatars/badge/CARD_FIRST_SWIPE/chip.webp differ diff --git a/public/avatars/badge/CARD_FIRST_SWIPE/wink.webp b/public/avatars/badge/CARD_FIRST_SWIPE/wink.webp new file mode 100644 index 0000000000..eb2517fa60 Binary files /dev/null and b/public/avatars/badge/CARD_FIRST_SWIPE/wink.webp differ diff --git a/public/avatars/badge/CARD_SPENT_1K/cheer.webp b/public/avatars/badge/CARD_SPENT_1K/cheer.webp new file mode 100644 index 0000000000..571f1e76df Binary files /dev/null and b/public/avatars/badge/CARD_SPENT_1K/cheer.webp differ diff --git a/public/avatars/badge/CARD_SPENT_1K/note.webp b/public/avatars/badge/CARD_SPENT_1K/note.webp new file mode 100644 index 0000000000..e8c5093cd0 Binary files /dev/null and b/public/avatars/badge/CARD_SPENT_1K/note.webp differ diff --git a/public/avatars/badge/CARD_SPENT_1K/stack.webp b/public/avatars/badge/CARD_SPENT_1K/stack.webp new file mode 100644 index 0000000000..b28ca36b0e Binary files /dev/null and b/public/avatars/badge/CARD_SPENT_1K/stack.webp differ diff --git a/public/avatars/badge/DEVCONNECT_BA_2025/rise.webp b/public/avatars/badge/DEVCONNECT_BA_2025/rise.webp new file mode 100644 index 0000000000..198ac44f6f Binary files /dev/null and b/public/avatars/badge/DEVCONNECT_BA_2025/rise.webp differ diff --git a/public/avatars/badge/DEVCONNECT_BA_2025/sun.webp b/public/avatars/badge/DEVCONNECT_BA_2025/sun.webp new file mode 100644 index 0000000000..f7c4e934f5 Binary files /dev/null and b/public/avatars/badge/DEVCONNECT_BA_2025/sun.webp differ diff --git a/public/avatars/badge/DEVCONNECT_BA_2025/wink.webp b/public/avatars/badge/DEVCONNECT_BA_2025/wink.webp new file mode 100644 index 0000000000..b0fb275cbb Binary files /dev/null and b/public/avatars/badge/DEVCONNECT_BA_2025/wink.webp differ diff --git a/public/avatars/badge/ETHFLORIPA_HUB/eth.webp b/public/avatars/badge/ETHFLORIPA_HUB/eth.webp new file mode 100644 index 0000000000..56bbc87477 Binary files /dev/null and b/public/avatars/badge/ETHFLORIPA_HUB/eth.webp differ diff --git a/public/avatars/badge/ETHFLORIPA_HUB/island.webp b/public/avatars/badge/ETHFLORIPA_HUB/island.webp new file mode 100644 index 0000000000..69ae8702f0 Binary files /dev/null and b/public/avatars/badge/ETHFLORIPA_HUB/island.webp differ diff --git a/public/avatars/badge/ETHFLORIPA_HUB/palm.webp b/public/avatars/badge/ETHFLORIPA_HUB/palm.webp new file mode 100644 index 0000000000..086c8bc6b4 Binary files /dev/null and b/public/avatars/badge/ETHFLORIPA_HUB/palm.webp differ diff --git a/public/avatars/badge/EVENT_ALUMNI/board.webp b/public/avatars/badge/EVENT_ALUMNI/board.webp new file mode 100644 index 0000000000..3fd96925bb Binary files /dev/null and b/public/avatars/badge/EVENT_ALUMNI/board.webp differ diff --git a/public/avatars/badge/EVENT_ALUMNI/cap.webp b/public/avatars/badge/EVENT_ALUMNI/cap.webp new file mode 100644 index 0000000000..cb3fc15291 Binary files /dev/null and b/public/avatars/badge/EVENT_ALUMNI/cap.webp differ diff --git a/public/avatars/badge/EVENT_ALUMNI/wink.webp b/public/avatars/badge/EVENT_ALUMNI/wink.webp new file mode 100644 index 0000000000..174a9cc33f Binary files /dev/null and b/public/avatars/badge/EVENT_ALUMNI/wink.webp differ diff --git a/public/avatars/badge/IRL_NOMADS/pack.webp b/public/avatars/badge/IRL_NOMADS/pack.webp new file mode 100644 index 0000000000..5eea332e9a Binary files /dev/null and b/public/avatars/badge/IRL_NOMADS/pack.webp differ diff --git a/public/avatars/badge/IRL_NOMADS/pocket.webp b/public/avatars/badge/IRL_NOMADS/pocket.webp new file mode 100644 index 0000000000..1a31ec0efe Binary files /dev/null and b/public/avatars/badge/IRL_NOMADS/pocket.webp differ diff --git a/public/avatars/badge/IRL_NOMADS/roll.webp b/public/avatars/badge/IRL_NOMADS/roll.webp new file mode 100644 index 0000000000..412e2dde13 Binary files /dev/null and b/public/avatars/badge/IRL_NOMADS/roll.webp differ diff --git a/public/avatars/badge/NAIJA/flag.webp b/public/avatars/badge/NAIJA/flag.webp new file mode 100644 index 0000000000..84703b4ee1 Binary files /dev/null and b/public/avatars/badge/NAIJA/flag.webp differ diff --git a/public/avatars/badge/NAIJA/wave.webp b/public/avatars/badge/NAIJA/wave.webp new file mode 100644 index 0000000000..15ecfe4b8f Binary files /dev/null and b/public/avatars/badge/NAIJA/wave.webp differ diff --git a/public/avatars/badge/NAIJA/wink.webp b/public/avatars/badge/NAIJA/wink.webp new file mode 100644 index 0000000000..09d40e3fc7 Binary files /dev/null and b/public/avatars/badge/NAIJA/wink.webp differ diff --git a/public/avatars/badge/NITA/letter.webp b/public/avatars/badge/NITA/letter.webp new file mode 100644 index 0000000000..4ad8b6e05c Binary files /dev/null and b/public/avatars/badge/NITA/letter.webp differ diff --git a/public/avatars/badge/NITA/sparkle.webp b/public/avatars/badge/NITA/sparkle.webp new file mode 100644 index 0000000000..0cff81e0fa Binary files /dev/null and b/public/avatars/badge/NITA/sparkle.webp differ diff --git a/public/avatars/badge/NITA/wink.webp b/public/avatars/badge/NITA/wink.webp new file mode 100644 index 0000000000..29134cea59 Binary files /dev/null and b/public/avatars/badge/NITA/wink.webp differ diff --git a/public/avatars/badge/OFFRAMP_USER/bolt.webp b/public/avatars/badge/OFFRAMP_USER/bolt.webp new file mode 100644 index 0000000000..83542254ea Binary files /dev/null and b/public/avatars/badge/OFFRAMP_USER/bolt.webp differ diff --git a/public/avatars/badge/OFFRAMP_USER/spark.webp b/public/avatars/badge/OFFRAMP_USER/spark.webp new file mode 100644 index 0000000000..0a67729bdf Binary files /dev/null and b/public/avatars/badge/OFFRAMP_USER/spark.webp differ diff --git a/public/avatars/badge/OFFRAMP_USER/wink.webp b/public/avatars/badge/OFFRAMP_USER/wink.webp new file mode 100644 index 0000000000..2eb1495096 Binary files /dev/null and b/public/avatars/badge/OFFRAMP_USER/wink.webp differ diff --git a/public/avatars/badge/OG_2025_10_12/coin.webp b/public/avatars/badge/OG_2025_10_12/coin.webp new file mode 100644 index 0000000000..0548ef1006 Binary files /dev/null and b/public/avatars/badge/OG_2025_10_12/coin.webp differ diff --git a/public/avatars/badge/OG_2025_10_12/link.webp b/public/avatars/badge/OG_2025_10_12/link.webp new file mode 100644 index 0000000000..7e794a1584 Binary files /dev/null and b/public/avatars/badge/OG_2025_10_12/link.webp differ diff --git a/public/avatars/badge/OG_2025_10_12/shades.webp b/public/avatars/badge/OG_2025_10_12/shades.webp new file mode 100644 index 0000000000..8b0978634d Binary files /dev/null and b/public/avatars/badge/OG_2025_10_12/shades.webp differ diff --git a/public/avatars/badge/SHHHHH/lips.webp b/public/avatars/badge/SHHHHH/lips.webp new file mode 100644 index 0000000000..ecf43401b1 Binary files /dev/null and b/public/avatars/badge/SHHHHH/lips.webp differ diff --git a/public/avatars/badge/SHHHHH/shush.webp b/public/avatars/badge/SHHHHH/shush.webp new file mode 100644 index 0000000000..0f4c9586d2 Binary files /dev/null and b/public/avatars/badge/SHHHHH/shush.webp differ diff --git a/public/avatars/badge/SHHHHH/wink.webp b/public/avatars/badge/SHHHHH/wink.webp new file mode 100644 index 0000000000..50c5eec939 Binary files /dev/null and b/public/avatars/badge/SHHHHH/wink.webp differ diff --git a/public/avatars/badge/WAITLIST_SKIP/key.webp b/public/avatars/badge/WAITLIST_SKIP/key.webp new file mode 100644 index 0000000000..7024c69938 Binary files /dev/null and b/public/avatars/badge/WAITLIST_SKIP/key.webp differ diff --git a/public/avatars/badge/WAITLIST_SKIP/keyhole.webp b/public/avatars/badge/WAITLIST_SKIP/keyhole.webp new file mode 100644 index 0000000000..d232d84134 Binary files /dev/null and b/public/avatars/badge/WAITLIST_SKIP/keyhole.webp differ diff --git a/public/avatars/badge/WAITLIST_SKIP/wink.webp b/public/avatars/badge/WAITLIST_SKIP/wink.webp new file mode 100644 index 0000000000..935918dce9 Binary files /dev/null and b/public/avatars/badge/WAITLIST_SKIP/wink.webp differ diff --git a/public/avatars/basic/apple.webp b/public/avatars/basic/apple.webp new file mode 100644 index 0000000000..527f07d40e Binary files /dev/null and b/public/avatars/basic/apple.webp differ diff --git a/public/avatars/basic/avocado.webp b/public/avatars/basic/avocado.webp new file mode 100644 index 0000000000..c58a687630 Binary files /dev/null and b/public/avatars/basic/avocado.webp differ diff --git a/public/avatars/basic/cactus.webp b/public/avatars/basic/cactus.webp new file mode 100644 index 0000000000..2530254823 Binary files /dev/null and b/public/avatars/basic/cactus.webp differ diff --git a/public/avatars/basic/cloud.webp b/public/avatars/basic/cloud.webp new file mode 100644 index 0000000000..983a52b58e Binary files /dev/null and b/public/avatars/basic/cloud.webp differ diff --git a/public/avatars/basic/cube.webp b/public/avatars/basic/cube.webp new file mode 100644 index 0000000000..00994bf5d9 Binary files /dev/null and b/public/avatars/basic/cube.webp differ diff --git a/public/avatars/basic/donut.webp b/public/avatars/basic/donut.webp new file mode 100644 index 0000000000..176093b367 Binary files /dev/null and b/public/avatars/basic/donut.webp differ diff --git a/public/avatars/basic/drop.webp b/public/avatars/basic/drop.webp new file mode 100644 index 0000000000..c9ef687b36 Binary files /dev/null and b/public/avatars/basic/drop.webp differ diff --git a/public/avatars/basic/egg.webp b/public/avatars/basic/egg.webp new file mode 100644 index 0000000000..4f8d5f84bd Binary files /dev/null and b/public/avatars/basic/egg.webp differ diff --git a/public/avatars/basic/fish.webp b/public/avatars/basic/fish.webp new file mode 100644 index 0000000000..5c803d953d Binary files /dev/null and b/public/avatars/basic/fish.webp differ diff --git a/public/avatars/basic/flower.webp b/public/avatars/basic/flower.webp new file mode 100644 index 0000000000..98586ef017 Binary files /dev/null and b/public/avatars/basic/flower.webp differ diff --git a/public/avatars/basic/frog.webp b/public/avatars/basic/frog.webp new file mode 100644 index 0000000000..b47e3c088e Binary files /dev/null and b/public/avatars/basic/frog.webp differ diff --git a/public/avatars/basic/gem.webp b/public/avatars/basic/gem.webp new file mode 100644 index 0000000000..a17782ab50 Binary files /dev/null and b/public/avatars/basic/gem.webp differ diff --git a/public/avatars/basic/ghost.webp b/public/avatars/basic/ghost.webp new file mode 100644 index 0000000000..4950a52b05 Binary files /dev/null and b/public/avatars/basic/ghost.webp differ diff --git a/public/avatars/basic/heart.webp b/public/avatars/basic/heart.webp new file mode 100644 index 0000000000..43fbfe3ab2 Binary files /dev/null and b/public/avatars/basic/heart.webp differ diff --git a/public/avatars/basic/leaf.webp b/public/avatars/basic/leaf.webp new file mode 100644 index 0000000000..9d4e0545b1 Binary files /dev/null and b/public/avatars/basic/leaf.webp differ diff --git a/public/avatars/basic/moon.webp b/public/avatars/basic/moon.webp new file mode 100644 index 0000000000..22d703fded Binary files /dev/null and b/public/avatars/basic/moon.webp differ diff --git a/public/avatars/basic/mushroom.webp b/public/avatars/basic/mushroom.webp new file mode 100644 index 0000000000..7a304b86ba Binary files /dev/null and b/public/avatars/basic/mushroom.webp differ diff --git a/public/avatars/basic/planet.webp b/public/avatars/basic/planet.webp new file mode 100644 index 0000000000..1b93b44833 Binary files /dev/null and b/public/avatars/basic/planet.webp differ diff --git a/public/avatars/basic/star.webp b/public/avatars/basic/star.webp new file mode 100644 index 0000000000..e97fecc144 Binary files /dev/null and b/public/avatars/basic/star.webp differ diff --git a/public/avatars/basic/sun.webp b/public/avatars/basic/sun.webp new file mode 100644 index 0000000000..87adbcec6f Binary files /dev/null and b/public/avatars/basic/sun.webp differ diff --git a/public/avatars/letter/a.webp b/public/avatars/letter/a.webp new file mode 100644 index 0000000000..28f00d3431 Binary files /dev/null and b/public/avatars/letter/a.webp differ diff --git a/public/avatars/letter/b.webp b/public/avatars/letter/b.webp new file mode 100644 index 0000000000..7ef2670a18 Binary files /dev/null and b/public/avatars/letter/b.webp differ diff --git a/public/avatars/letter/c.webp b/public/avatars/letter/c.webp new file mode 100644 index 0000000000..acda8dfe78 Binary files /dev/null and b/public/avatars/letter/c.webp differ diff --git a/public/avatars/letter/d.webp b/public/avatars/letter/d.webp new file mode 100644 index 0000000000..0795480fdf Binary files /dev/null and b/public/avatars/letter/d.webp differ diff --git a/public/avatars/letter/e.webp b/public/avatars/letter/e.webp new file mode 100644 index 0000000000..e35ec4fd9e Binary files /dev/null and b/public/avatars/letter/e.webp differ diff --git a/public/avatars/letter/f.webp b/public/avatars/letter/f.webp new file mode 100644 index 0000000000..9d511d6c6d Binary files /dev/null and b/public/avatars/letter/f.webp differ diff --git a/public/avatars/letter/g.webp b/public/avatars/letter/g.webp new file mode 100644 index 0000000000..e97a5ea5aa Binary files /dev/null and b/public/avatars/letter/g.webp differ diff --git a/public/avatars/letter/h.webp b/public/avatars/letter/h.webp new file mode 100644 index 0000000000..e67afc5685 Binary files /dev/null and b/public/avatars/letter/h.webp differ diff --git a/public/avatars/letter/i.webp b/public/avatars/letter/i.webp new file mode 100644 index 0000000000..86b331c590 Binary files /dev/null and b/public/avatars/letter/i.webp differ diff --git a/public/avatars/letter/j.webp b/public/avatars/letter/j.webp new file mode 100644 index 0000000000..4c56ec4cbb Binary files /dev/null and b/public/avatars/letter/j.webp differ diff --git a/public/avatars/letter/k.webp b/public/avatars/letter/k.webp new file mode 100644 index 0000000000..8f43390bbd Binary files /dev/null and b/public/avatars/letter/k.webp differ diff --git a/public/avatars/letter/l.webp b/public/avatars/letter/l.webp new file mode 100644 index 0000000000..0951caa4bd Binary files /dev/null and b/public/avatars/letter/l.webp differ diff --git a/public/avatars/letter/m.webp b/public/avatars/letter/m.webp new file mode 100644 index 0000000000..4b92fcdd8d Binary files /dev/null and b/public/avatars/letter/m.webp differ diff --git a/public/avatars/letter/n.webp b/public/avatars/letter/n.webp new file mode 100644 index 0000000000..c3b53209a7 Binary files /dev/null and b/public/avatars/letter/n.webp differ diff --git a/public/avatars/letter/o.webp b/public/avatars/letter/o.webp new file mode 100644 index 0000000000..e6cff320e1 Binary files /dev/null and b/public/avatars/letter/o.webp differ diff --git a/public/avatars/letter/p.webp b/public/avatars/letter/p.webp new file mode 100644 index 0000000000..3c02462fe3 Binary files /dev/null and b/public/avatars/letter/p.webp differ diff --git a/public/avatars/letter/q.webp b/public/avatars/letter/q.webp new file mode 100644 index 0000000000..c5b442c035 Binary files /dev/null and b/public/avatars/letter/q.webp differ diff --git a/public/avatars/letter/r.webp b/public/avatars/letter/r.webp new file mode 100644 index 0000000000..d8b7e23952 Binary files /dev/null and b/public/avatars/letter/r.webp differ diff --git a/public/avatars/letter/s.webp b/public/avatars/letter/s.webp new file mode 100644 index 0000000000..3c03fe7324 Binary files /dev/null and b/public/avatars/letter/s.webp differ diff --git a/public/avatars/letter/t.webp b/public/avatars/letter/t.webp new file mode 100644 index 0000000000..399467b5d2 Binary files /dev/null and b/public/avatars/letter/t.webp differ diff --git a/public/avatars/letter/u.webp b/public/avatars/letter/u.webp new file mode 100644 index 0000000000..f9937090d3 Binary files /dev/null and b/public/avatars/letter/u.webp differ diff --git a/public/avatars/letter/v.webp b/public/avatars/letter/v.webp new file mode 100644 index 0000000000..f86114de0f Binary files /dev/null and b/public/avatars/letter/v.webp differ diff --git a/public/avatars/letter/w.webp b/public/avatars/letter/w.webp new file mode 100644 index 0000000000..4353cc7f59 Binary files /dev/null and b/public/avatars/letter/w.webp differ diff --git a/public/avatars/letter/x.webp b/public/avatars/letter/x.webp new file mode 100644 index 0000000000..3db66b3d22 Binary files /dev/null and b/public/avatars/letter/x.webp differ diff --git a/public/avatars/letter/y.webp b/public/avatars/letter/y.webp new file mode 100644 index 0000000000..c5b88ed022 Binary files /dev/null and b/public/avatars/letter/y.webp differ diff --git a/public/avatars/letter/z.webp b/public/avatars/letter/z.webp new file mode 100644 index 0000000000..45273daabf Binary files /dev/null and b/public/avatars/letter/z.webp differ diff --git a/src/app/dev/fixtures/FixtureList.tsx b/src/app/dev/fixtures/FixtureList.tsx index a23ebb34d4..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,7 +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. - const href = `${fixture.route}?${FIXTURE_PARAM}=${name}` + const href = fixtureHref(fixture.route, name) return ( diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx new file mode 100644 index 0000000000..8a6256c08f --- /dev/null +++ b/src/components/Avatar/AvatarPicker.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useEffect, useRef, useState } 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 { badgeAvatarKeys, offerBasics } from './avatar.utils' +import { roveAvatarTiles } from './avatarPicker.utils' +import { UserAvatar } from './UserAvatar' + +interface AvatarPickerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * The profile avatar picker (TASK-22142): what the user's badges unlocked, + * 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. + */ +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 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) + + // The tile moves on tap; the slot behind the drawer moves after fetchUser + // 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 wanted = useRef(undefined) + const draining = useRef(false) + const pick = pending === undefined ? saved : pending + + const drain = async () => { + draining.current = true + try { + // 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() + } while (wanted.current !== undefined) + } 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(() => { + 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[], groupLabel: string) => { + const focusIndex = Math.max(0, keys.indexOf(pick ?? '')) + return ( +
+ {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(offer, t('basics'))} +
+
+ + + +
+
+
+
+ ) +} diff --git a/src/components/Avatar/UserAvatar.tsx b/src/components/Avatar/UserAvatar.tsx new file mode 100644 index 0000000000..648c3c4655 --- /dev/null +++ b/src/components/Avatar/UserAvatar.tsx @@ -0,0 +1,61 @@ +'use client' + +import Image from 'next/image' +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 { avatarSrc, letterAvatarSrc } from './avatar.utils' + +interface UserAvatarProps { + /** Display name: the source of the day-0 letter sticker and of the label. */ + name?: string + avatarKey?: string | null + size?: AvatarSize + className?: string +} + +/** + * 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) + const art = picked ?? letter + + if (!art) { + return name ? ( + + ) : ( + + ) + } + + return ( + + + + ) +} diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx new file mode 100644 index 0000000000..992af7c337 --- /dev/null +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -0,0 +1,241 @@ +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' + +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; name: string }[] } +} +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() + mockUpdateUserById.mockResolvedValue({ data: {} }) + mockFetchUser.mockResolvedValue(null) + mockUser = { + user: { + userId: 'u1', + username: 'satoshi', + avatarKey: null, + badges: [{ code: 'BUG_WHISPERER', name: 'Bug Whisperer' }], + }, + } +}) + +describe('AvatarPicker', () => { + it('lists one row of five basics and only the avatars of badges the user holds', () => { + renderWithIntl() + + expect(screen.getAllByRole('radio')).toHaveLength(8) + // human labels, not keys: badge name + slug, or the slug alone + 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() + }) + + it('tells a user with no badges where avatars come from', () => { + mockUser.user.badges = [] + renderWithIntl() + + expect(screen.getAllByRole('radio')).toHaveLength(5) + 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(B)) + + 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 = KEY_A + mockUpdateUserById.mockResolvedValue({ error: 'Avatar not unlocked' }) + renderWithIntl() + + fireEvent.click(radio(B)) + + 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): 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)) + + // (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 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 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() + + fireEvent.click(radio(A)) + fireEvent.click(radio(B)) + await server.reject(0) + + 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', () => { + mockUser.user.avatarKey = 'basic.apple' + const random = jest.spyOn(Math, 'random').mockReturnValue(0) + renderWithIntl() + const rowOf = () => + Array.from(screen.getByRole('radiogroup', { name: 'Basics' }).querySelectorAll('[role="radio"]')).map( + (el) => el.getAttribute('aria-label') + ) + const before = rowOf() + + random.mockReturnValue(0.99) + act(() => fireEvent.click(screen.getByRole('button', { name: 'Roll the dice' }))) + + expect(rowOf()).not.toEqual(before) + expect(rowOf()).toContain('apple') + expect(radio('apple')).toHaveAttribute('aria-checked', 'true') + expect(mockUpdateUserById).not.toHaveBeenCalled() + random.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/Avatar/__tests__/UserAvatar.test.tsx b/src/components/Avatar/__tests__/UserAvatar.test.tsx new file mode 100644 index 0000000000..8776bbdc60 --- /dev/null +++ b/src/components/Avatar/__tests__/UserAvatar.test.tsx @@ -0,0 +1,49 @@ +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', () => ({ + __esModule: true, + default: ({ unoptimized, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean }) => , +})) + +describe('UserAvatar', () => { + it('renders the picked character on its palette', () => { + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('src', '/avatars/basic/frog.webp') + expect(screen.getByRole('img', { name: 'Avatar for satoshi' })).toBeInTheDocument() + expect(container).not.toHaveTextContent('S') + }) + + // no pick → the initial as sticker art, not a letter in a circle + it('falls back to the letter sticker without a pick', () => { + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('src', '/avatars/letter/s.webp') + expect(container).not.toHaveTextContent('S') + }) + + it('treats a key the manifest does not know as no pick', () => { + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('src', '/avatars/letter/h.webp') + }) + + // last resort: a name the letter set cannot draw keeps the first-letter + // avatar (AvatarWithBadge firstLetterOnly, #2924) + it('falls back to the first-letter avatar for a name outside a-z', () => { + const { container } = render() + + expect(container.querySelector('img')).toBeNull() + expect(container).toHaveTextContent(/^0$/) + }) + + it('shows the generic user glyph with no name at all', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(container).toHaveTextContent('') + }) +}) 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..e299971a25 --- /dev/null +++ b/src/components/Avatar/__tests__/avatar.utils.test.ts @@ -0,0 +1,84 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import badgeAssets from '@/types/badge-assets.json' +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 + // 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}.webp`), + ...Object.entries(badgeAssets.avatars.badges).flatMap(([code, slugs]) => + slugs.map((slug) => `/avatars/badge/${code}/${slug}.webp`) + ), + ] + expect(paths.length).toBeGreaterThanOrEqual(26) + for (const path of paths) { + expect({ path, exists: existsSync(join(process.cwd(), 'public', path)) }).toEqual({ path, exists: true }) + } + }) + + // the letter set is the day-0 fallback and has no manifest entry, so nothing + // else checks it: one missing file is a broken image for every user whose + // name starts with that letter + it('has sticker art for every letter a-z under public/avatars/letter', () => { + for (const letter of 'abcdefghijklmnopqrstuvwxyz') { + const path = letterAvatarSrc(`${letter}oe`) + expect(path).toBe(`/avatars/letter/${letter}.webp`) + expect({ path, exists: existsSync(join(process.cwd(), 'public', path!)) }).toEqual({ path, exists: true }) + } + expect(letterAvatarSrc(' Satoshi ')).toBe('/avatars/letter/s.webp') + // a name the letter set cannot draw falls through to the initial avatar + expect(letterAvatarSrc('0xdead')).toBeNull() + expect(letterAvatarSrc('中本')).toBeNull() + expect(letterAvatarSrc('')).toBeNull() + expect(letterAvatarSrc(undefined)).toBeNull() + }) + + 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([]) + // 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', + '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.webp') + expect(avatarSrc('badge.OFFRAMP_USER.wink')).toBe('/avatars/badge/OFFRAMP_USER/wink.webp') + expect(avatarSrc('basic.peanut')).toBeNull() + 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) + }) +}) 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 new file mode 100644 index 0000000000..fb1fba7893 --- /dev/null +++ b/src/components/Avatar/avatar.utils.ts @@ -0,0 +1,67 @@ +/** + * 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. 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 (
-
+
{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 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') }, },