feat(web): Epic: Spaces (#7168)

* feat: add shadcn/ui setup and infrastructure

Configuration:
- Add PostCSS config for Tailwind CSS v4
- Add cn utility for className merging (clsx + tailwind-merge)

Dependencies:
- Add @base-ui/react, tailwindcss, @tailwindcss/postcss
- Add shadcn component dependencies (class-variance-authority, clsx, lucide-react, etc.)
- Add shadcn CLI tool for component management
- Update lodash version

Infrastructure:
- Update globals.css with CSS variables and Tailwind imports
- Add use-mobile hook for responsive sidebar behavior
- Update _document.tsx to support dark mode via HTML class

* feat: add shadcn/ui component library

- Add components.json with base-vega style configuration
- Add 48 shadcn/ui components including:
  - Core: button, card, input, label, select, textarea
  - Layout: sidebar, separator, scroll-area, resizable
  - Feedback: alert, alert-dialog, badge, progress, skeleton, spinner, sonner
  - Navigation: breadcrumb, navigation-menu, pagination, tabs
  - Overlay: dropdown-menu, context-menu, popover, hover-card, tooltip, sheet, drawer
  - Form: checkbox, radio-group, switch, slider, combobox, input-otp, native-select, field, input-group
  - Data: table, avatar, accordion, collapsible
  - Misc: aspect-ratio, empty, kbd, toggle
  - Stories: button.stories.tsx, card.stories.tsx, avatar.stories.tsx

* chore: add design system AI skills and update documentation

Add AI skills for Figma design workflow:
- design.figma-to-code: Convert Figma designs to React code
- design.sync-component: Sync components from Figma
- design.sync-variables: Sync CSS variables from Figma
- design.verify: Verify pixel-perfect implementation
- design.prototype: Create Storybook prototypes

Add Figma component mapping documentation and remove autodocs tag
from Storybook examples in AGENTS.md to align with project standards.

* docs: improve JSDoc documentation for UI components

- Add comprehensive JSDoc comments to all UI components
- Include usage examples and key props documentation
- Verify and update examples against shadcn/ui Base UI documentation
- Improve examples for checkbox, collapsible, slider, alert, progress, select, tabs, accordion, switch, and radio-group components

* fix: rewrite story files to use Base UI patterns instead of Radix

- Replace asChild prop with render prop pattern (Base UI)
- Fix unused imports and unescaped entities
- Add required args property for stories with render functions
- All story files now work correctly with Base UI components

* chore: update skills

* refactor: move stories to stories dir

* fix(web): use bare imports for CSS files so they are inlined in production build

@import url(./vars.css) and @import url(./onboard.css) were silently
dropped during next build because @tailwindcss/postcss only resolves
bare @import specifiers, not url() imports. In dev mode the dev server
resolved them at request time, causing a visual mismatch between dev
and production builds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): preserve DM Sans font over Tailwind default

The @theme inline block had --font-sans: var(--font-sans), a circular
self-reference that resolved to Tailwind's default sans-serif stack
instead of the legacy DM Sans font.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): inline DM Sans font-face imports in globals CSS

* chore: update yarn.lock after merging dev

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: exclude shadcn UI components from CodeScene analysis

- Add .codescene.yml to exclude apps/web/src/components/ui/** from analysis
- Add README explaining shadcn components are semi-auto generated
- Prevents false positives for code duplication and complexity in generated code

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: isolate shadcn/Tailwind CSS to prevent conflicts with MUI

PROBLEM:
- shadcn/Tailwind CSS was imported globally in globals.css
- This caused visual regressions and conflicts with MUI design system
- Icons, spacing, and components were affected

SOLUTION:
- Remove all Tailwind/shadcn imports from globals.css
- Remove shadcn CSS variables and theme configuration from globals.css
- Create separate shadcn.css file with isolated styles
- shadcn.css is scoped to .shadcn-scope class
- shadcn components remain available for Storybook/future migration

RESULT:
- MUI design system is now active and unaffected
- shadcn system isolated for future migration only
- No visual regressions from CSS conflicts

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: start epic branch for Spaces storybook

* fix: shadcn imports for Storybook preview

* fix: shadcn imports for Storybook preview

* fix: add missing shadcn CSS and scope decorator to Vite Storybook

The Vite Storybook preview was missing the shadcn.css import and
ShadcnScopeDecorator, causing shadcn/ui components to render unstyled.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: update snapshots

* feat(web/Spaces): Storybook stories for the new header (#7175)

* feat: new Header Navigation + story

* chore: update snapshots

* chore: rm comments

* feat(web): Spaces SIWE flow (#7176)

* feat(spaces): setup SIWE on the home page

* feat(spaces): redirect user to welcome page when he disconnects his wallet

* feat(web): create onboarding flow

* feat(web): redirect user to the onboarding or spaces dashboard after he is connected

* feat(web): add guard to protect internal routes

* fix(web): refresh issue in the onboarding flow

* fix(web): change onboarding urls

* feat(web): add routes guard on application root level

* chore(web): refactor guard complexity

* fix(web): guard redirects

* fix(web): lint & prettier errors

* fix(web): unit tests

* fix(web): improve readability in SIWE authentication

* feat(web): cover main flow hooks with unit tests

* feat(web): redirect user to space creation workflow when clicking in the create space link in the sidebar

* feat(web/Spaces): Storybook stories for the new Sidebar (#7166)

* feat: initial sidebar UI + stories

* refactor: styling cleanup

* fix: UI fixes for the top bar

* refactor: styles and props adjustments

* chore: update snapshots

* refactor: add sidebar variants

* feat: add a new SpaceCard component + the story

* feat(web/Spaces): Safe Selector dropdown (#7156)

* feat: initial SafeSelector dropdown + story

* feat: update chain and safe based on user selection

* fix: minor UI fixes

* fix: chosen Safe is not in dropdown anymore

* refactor: types

* fix: chain logo size

* fix: chain logo size

* refactor: improve code readability

* refactor: balance formatting

* fix: shadcn imports for Storybook preview

* refactor: simplify files structure for better readability

* chore: formatting

* feat: Single Safe Single Chain UI

* refactor: simplify hooks

* refactor: simplify hooks for the safe selector dropdown

* chore: update snapshots

* refactor: new atomic subcomponents, remove all hook dependencies

* refactor: cleanup

* refactor: divide bigger components into atomic ones

* refactor: move some selector logic into a hook

* refactor: hook refactoring to reduce cyclomatic complexity

* feat: nested safes in the dropdown

* feat(web): Create accounts and pending tx widgets to add on spaces dashboard later (#7187)

* feat(web): create SafeWidget compouded component to allow layout flexibility and have a better separation of concerns

* feat(web): create storybook for SafeAccountWidget

* feat(web): create storybook for the widget of the pending transactions

* feat(web): add loading state and skeleton components to Accounts and Pending Transactions widgets

* feat(web): remove accounts and pending tx stories from storybook

* fix(web): remove storybook title to show the components under its folder level on storybook

* chore(web): generated snapshots

* feat: add dashboard header component (#7188)

* chore(web): update storybook snapshots after dev merge

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): add RouterDecorator to stories using EthHashInfo after Spaces migration (#7230)

* feat(web): Integrate onboarding components on spaces flow (#7223)

* feat(web): add address book onboarding step and step indicator component

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): move onboarding components into spaces feature directory

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): fix spaces onboarding component imports and props

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): restructure invite members form

* chore(web): restructure invite members component

* feat(web): increase wrapper container width

* feat(web): add storybook stories for onboarding components

Add missing stories for SelectSafesOnboarding and StepIndicator.
Fix CORS errors in existing onboarding stories by adding spaceId
query param and mocking spaces API endpoints (owned safes, safe
overviews, members, mutations) via MSW handlers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): adjust types import

* fix(web): fix type import and UseGuard type errors

Replace inline `import()` type annotations with proper UseFormReturn import
and remove unused parameter from UseGuard type to match implementation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web/Spaces): New sidebar - Spaces level (#7226)

* feat: initial Spaces sidebar built into the UI

* feat: extended props and hooks for the spaces selection dropdown

* feat: Spaces selector dropdown

* feat: navigation in the Spaces sidebar

* fix: minor UI fixes for the Spaces sidebad

* fix: UI fixes for minimized sidebar and the Help section

* feat: sidebar for mobile

* feat: remember sidebar state

* fix: Space selector dropdown on mobile; formatting

* fix: z-index for sidebar on mobile

* fix: remove tx number

* fix: disabled Security + Settings btns for non-members

* fix: stories UI for the Spaces-level sidebar

* refactor: remove nested buttons

* chore: linting

* test: add tests for the Spaces Sidebar

* chore: named imports and import updates for better readability

* tests: update tests

* refactor: rename useIsActiceMember to useIsActiveMember

* feat: add aria labels for accessibility

* refactor: separate file and tests for utils functions

* test: add test ids to Tx; extract the id getter to utils

* refactor: adjust tests; move consts in a separate file

* chore: linting

* feat(web): replace spaces dashboard balance card with action header (#7224)

* feat(web): add address book onboarding step and step indicator component

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): move onboarding components into spaces feature directory

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): fix spaces onboarding component imports and props

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): restructure invite members form

* chore(web): restructure invite members component

* feat(web): increase wrapper container width

* feat(web): add storybook stories for onboarding components

Add missing stories for SelectSafesOnboarding and StepIndicator.
Fix CORS errors in existing onboarding stories by adding spaceId
query param and mocking spaces API endpoints (owned safes, safe
overviews, members, mutations) via MSW handlers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): adjust types import

* fix(web): fix type import and UseGuard type errors

Replace inline `import()` type annotations with proper UseFormReturn import
and remove unused parameter from UseGuard type to match implementation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): add aggregated balance header with actions to spaces dashboard

Replace the chain-breakdown balance card with a new DashboardHeader
that shows total value and quick-action buttons (send, swap, build tx).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: fix action buttons wrap in mobile browser

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Co-authored-by: Tong Yao <elijahyaochn@gmail.com>

* Redesign accounts component (#7246)

* feat(web): add address book onboarding step and step indicator component

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): move onboarding components into spaces feature directory

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): fix spaces onboarding component imports and props

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): restructure invite members form

* chore(web): restructure invite members component

* feat(web): increase wrapper container width

* feat(web): add storybook stories for onboarding components

Add missing stories for SelectSafesOnboarding and StepIndicator.
Fix CORS errors in existing onboarding stories by adding spaceId
query param and mocking spaces API endpoints (owned safes, safe
overviews, members, mutations) via MSW handlers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): adjust types import

* fix(web): fix type import and UseGuard type errors

Replace inline `import()` type annotations with proper UseFormReturn import
and remove unused parameter from UseGuard type to match implementation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): redesign spaces dashboard AccountsWidget

Integrate AccountItem.Balance, AccountItem.ChainBadge, and useSpaceAccountsData
hook into the AccountsWidget. Replace MUI components with shadcn equivalents in
AccountItemChainBadge. Add clickable account items with router.push navigation,
hover states, and unit tests.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): use AccountItemChainBadge in SafeCardLayout and polish widget

Replace ChainLogo with AccountItem.ChainBadge in the SafeCardLayout
onboarding component. Adjust dashboard grid and list limit, and minor
widget layout tweaks.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): only render AccountsWidget footer when remainingCount is defined

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): remove conditional margin on WidgetFooter text

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): use correct shadcn Button variant in AddAccounts dialog

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): split AccountsWidget item into separate components

Extract AccountWidgetItem (single-chain), ExpandableAccountItem (multi-chain),
and AccountItemContent (shared row layout) into separate files. Hide threshold
badge when account has subAccounts.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: fix prettier formatting in chromatic workflow

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): add Redux provider to AccountsWidget stories

FiatValue uses useAppSelector which requires a Redux context.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): resolve nested button hydration error in AccountItemChainBadge

Render TooltipTrigger as a <span> instead of <button> to avoid
invalid HTML nesting when used inside CollapsibleTrigger.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): move AccountsWidget to feature architecture pattern

* fix(web): add data-testid back

* fix(web): unit tests for the account widget

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor: refactor dashboard header (#7252)

* feat(web): add address book onboarding step and step indicator component

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): move onboarding components into spaces feature directory

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): fix spaces onboarding component imports and props

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): restructure invite members form

* chore(web): restructure invite members component

* feat(web): increase wrapper container width

* feat(web): add storybook stories for onboarding components

Add missing stories for SelectSafesOnboarding and StepIndicator.
Fix CORS errors in existing onboarding stories by adding spaceId
query param and mocking spaces API endpoints (owned safes, safe
overviews, members, mutations) via MSW handlers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): adjust types import

* fix(web): fix type import and UseGuard type errors

Replace inline `import()` type annotations with proper UseFormReturn import
and remove unused parameter from UseGuard type to match implementation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): add aggregated balance header with actions to spaces dashboard

Replace the chain-breakdown balance card with a new DashboardHeader
that shows total value and quick-action buttons (send, swap, build tx).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: fix action buttons wrap in mobile browser

* refactor: refactor dashboard header

---------

Co-authored-by: Clovis da Silva Neto <clovisdasilvaneto@gmail.com>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web/Spaces): New sidebar - Safe level (#7251)

* feat: initial Spaces sidebar built into the UI

* feat: extended props and hooks for the spaces selection dropdown

* feat: Spaces selector dropdown

* feat: navigation in the Spaces sidebar

* fix: minor UI fixes for the Spaces sidebad

* fix: UI fixes for minimized sidebar and the Help section

* feat: sidebar for mobile

* feat: remember sidebar state

* fix: Space selector dropdown on mobile; formatting

* fix: z-index for sidebar on mobile

* fix: remove tx number

* fix: disabled Security + Settings btns for non-members

* fix: stories UI for the Spaces-level sidebar

* refactor: remove nested buttons

* chore: linting

* test: add tests for the Spaces Sidebar

* chore: named imports and import updates for better readability

* tests: update tests

* refactor: rename useIsActiceMember to useIsActiveMember

* feat: add aria labels for accessibility

* refactor: separate file and tests for utils functions

* test: add test ids to Tx; extract the id getter to utils

* refactor: adjust tests; move consts in a separate file

* chore: linting

* feat: initial Safe sidebar

* fix: remove overlapping tooltip to make sidebar links clickable

* fix: dark theme for shadcn

* fix: minor UI fixes for the toggled sidebar

---------

Co-authored-by: Clóvis Neto <clovisdasilvaneto@gmail.com>

* tests: update storybook snapshots for chain/network logo min-width (#7258)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web/Spaces): UI fixes; Receive btn fixes (#7260)

* fix: UI fixes; Receive btn in Space opens the QR code modal for the 1st safe

* fix: Sidebar styles

* fix: Sidebar Tx btn stays active on both /transactions/history and /transactions/queue

* fix(web): remove flaking test

* feat(web): move accounts widget to be part of MyAccounts feature (#7264)

* feat: create new safe account overview (#7262)

* fix(web/Spaces): hydration guard for the sidebar + stories adjustments (#7274)

* feat: hydration guard for the sidebar; adjust stories; add sidebar skeleton

* tests: add tests for the hydration guard and for stories

* fix(web/Spaces): multiple fixes for the Safe-level dashboard (#7278)

* chore: remove deprecated mark from components that are not deprecated

* fix: every tx in the Pending Tx widget is clickable

* fix: show View All Assets btn in case if N Assets >0; adjust styles for the widget footer

* fix: makwe the entire Assets widget clickable

* fix: show "No tx / No assets" text when no tx or no assets for the account

* fix: Manage Safe btn styles

* fix: UI fixes across new compoennts

* fix: loading spinner when balances loading instead of 0

* fix: hide Tx and Security links in the Spaces Sidebar

* fix: styles for the Pending Tx description

* fix: CTA styles

* fix: CTA styles

* refactor: use existing formatting function for hte Pending TX

* test(web/Spaces): extend Sidebar test suite (#7298)

* tests: add new Sidebar tests; adjust existing ones; remove redundant

* tests: add tests for Geoblocking for the sidebar

* fix: checkbox / toggle border

* chore: typing

* chore: minor cleanup

* chore: remove redundant mock

* feat(web): add topbar with wallet and notifications popovers (#7290)

* feat(web): add topbar with wallet and notifications popovers

Introduce a new Topbar component replacing the old Header, with wallet
popover as a lazy-loaded feature and notifications popover. Update
HeaderNavigation to accept mouse events and refine button sizing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): restore missing data-testid attrs and renewal hook in NotificationsPopover

Add back useShowNotificationsRenewalMessage() call and data-testid
attributes that existed in the old NotificationCenter component.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): read unread count from Redux instead of stale ref

The notification badge never updated because unreadCount was read from
a ref during render, which doesn't trigger re-renders. Now Topbar reads
directly from the Redux store so the badge updates reactively.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* tests(web): add unit tests for Topbar, NotificationsPopover and useNotificationsPopover

Cover the notification badge reactive update from Redux, data-testid
attributes, push notifications settings visibility, unread count display,
chronological sorting, expand limit, clear all, and the renewal hook call.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): remove wallet feature flag

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore(design): fix design systems, wrap all Storybook stories with ShadcnProvider (#7307)

* chore: align shadcn design system with dev branch

Remove CSS workarounds from epicSpaces that were superseded by the
scoped Tailwind preflight merged to dev (#7227). Reset globals.css,
shadcn.css, and Storybook preview configs to match dev while keeping
the popover color and sidebar z-index customizations needed by Spaces.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: wrap all Storybook stories with ShadcnProvider

Non-UI stories (e.g. Spaces) that use shadcn components need the
.shadcn-scope CSS variables for proper rendering, especially in dark
mode. The real app wraps everything with ShadcnProvider — Storybook
should match that.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: make ShadcnProvider opt-in for Storybook stories

Remove global ShadcnProvider wrapping from preview.tsx. Instead:
- fullPage/withSidebar layouts auto-wrap with ShadcnProvider (sidebar needs it)
- Stories opt in via `shadcn: true` on withMockProvider/createMockStory
- UI/ stories still get ShadcnProvider globally (unchanged)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: move shadcn CSS import out of global Storybook preview

Move shadcn.css and shadcn-stories.css imports from preview.tsx into
a dedicated .storybook/shadcn.ts module that co-locates the CSS with
ShadcnProvider. This keeps the global preview clean while ensuring
shadcn stories still get their CSS (scoped to .shadcn-scope).

Also removes a duplicate shadcn.css import in _app.tsx.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): use previous queue logic in the new pendingTx list (#7302)

* fix(web): use previous queue logic in the new pendingTx list

* chore(web): fix prettier formatting in SafeWidgetRoot and PendingTxList

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: bring back the old dashboard safe view (#7310)

* fix: bring back the old dashboard safe view

* fix: add new overview to route

* fix: undeprecate the old files

* fix: resolve spaces dashboard refresh issue (#7311)

Await connectLastWallet to ensure wallet is ready before setting
walletReady flag, fix router guard to only check SIWE authentication
(not wallet connection), add loading state for AccountsWidget, and
remove debug console.log statements.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): storybook build (#7317)

* fix(web): remove SIWE requirement on home screen and clean up flow guard (#7314)

* fix: add back the old topbar and sidebar for safe view (#7318)

* fix: add back the old topbar and sidebar for safe view

* fix: fix prettire and fix the tests failing caused by PR #7314

* chore: update snapshots (#7325)

* feat(web/Spaces): Remove the Addressbook step and CTA buttons from the Spaces UI (#7324)

* feat: remove the CTA buttons from Spaces Dashboard UI

* feat: no Address Book step in the Onboarding flow

* chore: remove redundant Addressbook

* fix: Create Space btn redirects to the new Onboarding flow instead of old modal (#7332)

* feat(web): add empty, error, and identicon states to AccountsWidget (#7340)

* feat(web): add empty, error, and identicon states to AccountsWidget

Add SafeWidget.EmptyState and SafeWidget.ErrorState compound components
for reusable widget states. Wire them into AccountsWidget with identicon
avatars using blo, and cover with unit tests and Storybook stories.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* tests(web): fix useFlowActivationGuard tests after SIWE guard refactor

Mock useIsSpaceRoute and use space routes in tests that verify redirect
behavior, since non-space routes are now intentionally public.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* tests(web): fix remaining useFlowActivationGuard test assertions

Update 3 tests to expect redirect to /welcome for space routes that are
not public: unauthenticated users and invalid spaceId scenarios.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): allow unselecting already-added safes in onboarding (#7347)

* feat(web): pass spaceId to create-space page for editing existing spaces

When navigating back from select-safes to create-space, the spaceId is
now passed as a query parameter. The create-space page fetches the
existing space name, shows a loading spinner, and uses the update API
instead of create when editing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): allow unselecting already-added safes in onboarding and fix skip navigation

- Remove alreadyAdded guard so users can unselect pre-checked safes
- Pre-populate form with already-added safes as initial checked state
- Add remove API call for unselected safes on submit
- Only call add/remove APIs when there are actual changes (fixes empty array validation error)
- Fix skip button to navigate to invite members step instead of dashboard

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): adjust code complexity

* tests(web): add unit tests for useOnboardingSubmit hook

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): fix type error in useSpaceSubmit catch block

Replace @ts-ignore with proper type assertion for error object.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): replace ts-ignore with getRtkQueryErrorMessage

Use the existing getRtkQueryErrorMessage utility instead of @ts-ignore
or manual type casts for RTK Query error handling in space hooks.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* tests(web): fix useOnboardingSubmit error mock shapes

Add missing `status` property to RTK Query error mocks so
getRtkQueryErrorMessage treats them as FetchBaseQueryError
instead of SerializedError.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): reduce complexity in useOnboardingSubmit and fix ts-ignore in SafeCard

Extract processSelectedSafes helper to reduce onSubmit complexity.
Replace @ts-ignore with proper type assertion for watch() overload.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat: Add dropdown list to safe view (#7354)

* feat(web/Spaces): API component in the sidebar (#7369)

* feat: initial API Sidebar component + story

* feat: remove the Prod CGW toggle from the sidebar

* fix: styles for minimized sidebar

* feat: invert SVG colors when in dark mode

* fix: UI fixes for the API Sidebar

* test: add tests for the new API Sidebar component

* feat: sidebar on mobile screens + tests

* refactor: reduce conditional complexity

* feat: color marking for spaces

* refactor: replace inline calls with var

* fix: spaces input fields sanitization and address validation (#7368)

* fix: remove console.log

* fix: input validations

* fix: onboarding on dark mode

* fix(web): wait until useRouter is ready

* fix(web): do not allow user to select duplicated address

* refactor: extract address validation and fix debounced error display

Extract validateEthereumAddress into a standalone function to reduce
cyclomatic complexity. Fix debounce logic to operate on the error
message string directly instead of the error object.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): add back to space button and mobile responsive fixes (#7380)

* feat: add Typography component and migrate epicSpaces to use it (#7384)

* feat: add Typography component to design system

- Add Typography component with variant, align, color props
- Map Figma typography styles to component variants
- Add m-0 to reset default margins on block elements
- Add design.figma-to-code reference for Typography mapping


* chore: add motion resolution for Storybook

Force motion@12.34.0 to fix motion/react import resolution in Vite Storybook


* feat: migrate epicSpaces components to Typography

Replace raw Tailwind/MUI typography with Typography component in:
- spaces: SafeWidget, SafeSelectorDropdown, onboarding, Sidebar, SpaceCardNew, TotalValueElement
- myAccounts: AccountItemBalance, AccountItemContent


* feat(web): implement trusted safe and similar address detection in onboarding flow (#7389)

* feat(web/Spaces): Dark theme adjustments (#7398)

* feat: adjust shadcn tokens for dark mode; adjust dark mode view in Spaces

* feat: Safe logo changes color depending on theme

* fix: sidebar elements colors in dark mode

* test: adjust unit tests

* feat: adjust font weight for Add Accounts btn

* refactor: add a mask for SVG color swap (dark mode) to decrease the bundle

* fix: remove autofill so the browser does not apply white bg in dark theme automatically

* fix(web/Spaces): redirect to `/welcome/spaces` when not SIWE-authenticated (#7383)

* fix: redirect user to the welcome page if auth is expired

* refactor: move the redirect logic to the Flow Activation Guard to avoid having multiple hooks

* test: add tests for the redirection logic

* fix: remove redundant isStoreHydrated check as it was duplicated

* refactor: rm redundant code; feat: go to /welcome/spaces (not /welcome) when redirected from Spaces route

* fix(web/Spaces): UI fixes for layout shift and border raduis (#7419)

* fix: layout shift fixes which were introduced after design system upgrade

* fix: Address Book layout

* fix: btn radius sm (8px) instead of md or lg

* chore: formatting

* feat(web): pending transactions widget for spaces dashboard (#7408)

* feat(web): add subtitle prop to WidgetEmptyState

Support an optional subtitle below the main text, using paragraph-bold
for the title and paragraph-small for the subtitle.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(web): redesign WidgetErrorState to match Figma

Update icon from TriangleAlert to CircleAlert, add subtitle text,
change default message to "Unable to load content", and switch
reload button to primary variant with "Reload page" label.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* feat(mobile): redesign ErrorWidget to match Figma

Add alert icon badge, configurable message prop, subtitle text,
and optional reload button. Include unit tests covering all states.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: accounts appearance

* feat(web): fetches queued transactions across all safes in a space. Adds the
  useSpacePendingTransactions hook with tests, updates PendingTxWidget to
  render actual transaction data, and exports getTransactionQueue cursor
  option.

* fix(web): accounts widget font and topbar spacement

* feat(web): fix assets widget unit tests

* feat(web): add navigation to transaction detail from pending tx widget

Link each pending transaction item in the spaces dashboard to its
detail page using the safe query param (shortName:address).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: fix knip-export by claude

* fix: import pendingTxWidget by useLoadFeature

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Co-authored-by: Tong Yao <elijahyaochn@gmail.com>

* fix(web/Spaces): consistent colors for Space avatars across the app (#7429)

* fix: deterministic avatar color for Spaces on all pages

* fix: UI: Spaces avatars shape in the dropdown

* refactor: move classes to the css module

* feat: add ident icon and bold address (#7411)

* fix(web/Spaces): remove obsolete buttons (#7430)

* feat: remove obsolete buttons

* fix: add mock provider for Storybook stories to enable correct story rendering

* fix(web): add special characters validation to space name field (#7432)

Adds a pattern validation to the space name input that only allows
letters, numbers, and spaces. Also updates maxLength from 50 to 30.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web/Spaces): remove accounts counter (#7434)

* feat: remove accpounts counter; add new prop to define the View All layout in the custom widget

* fix: show identicon for multichain Safes

* fix: revert back the address fix to avoid conflicts

* tests(web): fix flaky visual tests and add spaces welcome screenshot (#7431)

Remove enableChainFeature() calls from visual tests — the feature flags
are already in the chains fixture, and enableChainFeature uses
req.continue() which bypasses fixture mocks and hits the real staging
server, causing flaky failures.

Also add visual test coverage for the /welcome/spaces page.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): refactor AccountWidgetItem to use shared WidgetItem component (#7435)

* fix(web): refactor AccountWidgetItem to use WidgetItem component

Align AccountWidgetItem with the shared WidgetItem layout used by
PendingTxWidget. Add hover effect to WidgetFooter for consistency.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): pass full address instead of shortened in account data

Remove id field from Account type and pass the full address so
components can use it for Identicon rendering and shorten as needed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): remove id from Account type, fix references to use address

Update tests and stories to use full-length addresses now that
components handle shortening internally.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web/Spaces): UI fixes for the sidebar and the dark mode (#7445)

* fix: sidebar UI fixes

* fix: UI foxes for the Sidebar footer

* fix: theme-dependent colors foer icons in Pending Tx Widget

* feat: expand layout when sidebar is collapsed

* feat: primary and secondary btns adjustments; badge for API CTA

* test: adjust tests

* fix: decrease sidebar z-index

* test: add tests; refactor: rm hardcoded css values

* fix: API CTA text; background color in dark mode (#7451)

* feat(web): implement chain selector and fix ui styling (#7442)

* feat: chain selector

* refactor: remove unused hasChains variable from useSafeSelectorState

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: fix the height of account selector

* fix: adjust the hover background color and the position of dropdown list

* fix: remove the boarder color of dropdown list

* fix: adjust shadow and padding of logo

* fix: delete the vertical line between fiat value and chevron

* fix: only show SpaceSafeBar in home page

* fix: fix the storybook component

* feat: add display naming hook

* fix: fix claude comment

* fix: skip infura api call when the name existing

* fix: disable the ens name for selectorDropDowb

* fix: fix the chain selector story

* fix: fix the black border of the chain selector block

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): onboarding design QA improvements (#7443)

* fix(web): refactor AccountWidgetItem to use WidgetItem component

Align AccountWidgetItem with the shared WidgetItem layout used by
PendingTxWidget. Add hover effect to WidgetFooter for consistency.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: change copy

* fix(web): onboarding design QA improvements

- Fix role dropdown positioning to open below and left-aligned
- Reduce gap between submit and skip buttons to 20px
- Add chain prefix stripping on paste for address inputs
- Extract SafeCardLayout component and fix shortened address test

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: fix type-only import in SafeCardLayout

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): remove undefined property

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): fix PageLayout CSS module purity error and space dashboard UI fixes (#7446)

Fix CSS module selector purity error by removing :global(:root) and using
@reference for Tailwind utilities. Adjust topbar positioning, notification
badge styling, and popover anchor offsets for spaces dashboard.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): remove redirect to welcome screen on wallet disconnect (#7447)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): add animations and back button to CreateSpaceOnboarding (#7449)

Add staggered entry animations, animated background orbs, floating
Safe logo, and inline back button matching SelectSafesOnboarding pattern.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): redirect new users to create space after sign-in on spaces list (#7458)

Refactor useSignInRedirect to be driven by reactive spaces data instead
of imperative lazy queries. After signing in, new users (no spaces, no
invites) are redirected to the create-space page. The hook now receives
spacesAmount, inviteAmount, isSpacesLoading, and error as props from
SpacesList, and uses a hasSignedIn flag to trigger the redirect effect.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web/Spaces): UI fixes for the Safes list in the Onboarding flow (#7454)

* fix(web): refactor AccountWidgetItem to use WidgetItem component

Align AccountWidgetItem with the shared WidgetItem layout used by
PendingTxWidget. Add hover effect to WidgetFooter for consistency.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: change copy

* fix(web): onboarding design QA improvements

- Fix role dropdown positioning to open below and left-aligned
- Reduce gap between submit and skip buttons to 20px
- Add chain prefix stripping on paste for address inputs
- Extract SafeCardLayout component and fix shortened address test

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore: fix type-only import in SafeCardLayout

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): remove undefined property

* fix: no vertical scrolling on the Add Safes page

* fix: theme-dependent scrollbar

* refactor: move too long tailwind classes in separate css modules for better readability

---------

Co-authored-by: Clovis da Silva Neto <clovisdasilvaneto@gmail.com>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web/Spaces): fix issues in the Onboarding flow (#7470)

* fix: onboarding flow flickering and back btn on the 1st step

* fix: add back inline css instead of modules

* fix(web): avoid rate limiting in space pending transactions (#7466)

* fix(web): avoid rate limiting in space pending transactions

Use Safe overview endpoint to pre-filter safes with queued
transactions before fetching individual queues. Adds sequential
requests with delay to prevent CGW rate limiting.

- Extract useSpaceSafesWithQueue hook using overview endpoint
- Serialize queue fetches with 300ms delay between requests
- Update SKELETON_COUNT to 4 for better loading UX
- Update tests to mock new hook

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(web): optimize space pending transactions fetching and improve loading UX

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): align test mock with isFetching used in useSpaceSafesWithQueue

The hook uses `isFetching` for the space safes query but the test mock
was returning `isLoading`, causing the isLoading assertion to fail.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: sanity check (#7477)

* feat(web/Spaces): Tracking events (#7388)

* feat: mapping of new Spaces events

* feat: tracking events for SIWE + tests

* fix: SIWE tracking events are firing for both GA and MP

* feat: add tracking events for Space creation

* feat: add events for Spaces Invites (sent / accepted / declined)

* test: add tests for tracking events to add member / accept / decline invite

* feat: add tracking events for Accounts Widget

* test: add a test for Accounts Widget tracking events

* feat: add tracking events (safe + chain) for the Safe Selector Dropdown

* test: add tests for Safe selection and chain change events

* feat: add tracking events fro Pending Tx Widget

* test: add tests for tracking eventa for Pending Tx Widget

* feat: tracking event for Spaces Entry view + tests for this event

* feat: add tracking events for wallet switch / wallet disconnected btn clicks on Spaces level

* fix: linting

* test: add a test to ensure event firing on spaceId change

* fix: wallet events should be tracked only in Spaces

* refactor: remove rediundant type check for undefined spaceId

* refactor: update imports, add mixpanel enum to events

* test: fix tests

* feat: upgrade network selector dropdown (#7468)

* fix(web/Spaces): multiple UI fixes for Spaces for small screens (#7479)

* fix: no overlap on mobile screens in the widget

* fix: topbar fixes for mobile screens

* fix: Account Widget styles on mobile screens

* fix: toggle switch styles for light mode

* fix: restore NFTs Feature export (#7478)

* fix: storybook typing

* fix(web/Spaces): main header redirect (#7481)

* fix: header redirect to /welcome/accounts instead of Spaces

* fix: impotrts

* fix: logo in the header redirects to /welcome/accounts always

* fix(web): fix white screen on onboarding flow (#7476)

* fix(web): fix white screen on onboarding flow

Remove wallet and authentication guard checks from onboarding
components that were causing white screen flashes. These checks
are redundant as the routing layer already handles auth guards.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): add outline variant for add account button in light mode

Also clean up unused wallet/auth imports in CreateSpaceOnboarding.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix(web): show connect wallet prompt when no wallet connected in onboarding

When no wallet is connected, the onboarding safe selection screen now
shows a connect wallet button instead of an empty safes list.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* fix: fix button margin and drop down (#7482)

* fix: fix styling of safespacebar

* fix: fix the component height

* fix: adjust No Assets layout for Safes with TVL 0 (#7484)

* fix: correct copy for Spaces Onboarding

* fix: delete the z-index (#7487)

---------

Co-authored-by: valleXYZ <valentin.seehausen@gmail.com>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Co-authored-by: Clóvis Neto <clovisdasilvaneto@gmail.com>
Co-authored-by: Tong Yao <51960333+ChillYao@users.noreply.github.com>
Co-authored-by: katspaugh <katspaugh@gmail.com>
Co-authored-by: Tong Yao <elijahyaochn@gmail.com>
This commit is contained in:
Varya
2026-03-19 15:44:20 +01:00
committed by GitHub
co-authored by valleXYZ Hanzo Dev Clóvis Neto Tong Yao katspaugh Tong Yao
parent 420c9d6bfb
commit 1b99cb9caa
319 changed files with 17170 additions and 661 deletions
+7 -2
View File
@@ -71,6 +71,7 @@ For shadcn Figma libraries, extract props from **attributes only**:
- CSS variable **names**: `--general/primary`, `--general/secondary`
- `data-name` for component type
- **Text:** Use `get_variable_defs` — map Figma text styles (e.g. `heading 2`, `paragraph/regular`) to Typography variants (see reference.md)
**What to IGNORE:**
@@ -83,6 +84,8 @@ For shadcn Figma libraries, extract props from **attributes only**:
2. CSS variable names → variant (`--general/secondary``variant="secondary"`)
3. **Omitted = default**
**Typography:** NEVER use hardcoded Tailwind for text. Always use `<Typography variant="…" />` from `@/components/ui/typography`. Map Figma variable names (e.g. `heading 2`, `paragraph-bold`) to Typography variants (e.g. `h2`, `paragraph-bold`).
## Step 5: Build the Component
```tsx
@@ -105,9 +108,10 @@ export function MyComponent({ ...props }: MyComponentProps) {
DO:
- Use shadcn variants (`variant="outline"`, `size="sm"`)
- Use Tailwind for layout: `flex`, `grid`, `gap-*`, `p-*`
- Use **Typography** with variants for all text — never raw Tailwind for typography
- Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`
- Match layout ratios from Figma exactly (e.g., `grid-cols-2` for 50/50 split, or specific column ratios)
- Prefer using wrapper classes for layout, to use pure shadcn components without added tailwing classes
- Prefer using wrapper classes for layout, to use pure shadcn components without added tailwind classes
- Use CSS variables from shadcn for colors
DON'T:
@@ -116,6 +120,7 @@ DON'T:
- Override shadcn styles
- Hardcode pixel values
- Wrap icons in divs inside buttons (icons should be direct children)
- Hardcode Tailwind for text
## Step 6: Create Storybook Stories
@@ -1,5 +1,54 @@
# Figma to Code Reference
## Typography (CRITICAL)
**Never hardcode Tailwind classes for text.** Always use the `Typography` component with variants.
```tsx
import { Typography } from '@/components/ui/typography'
// ✅ Correct
<Typography variant="h2" align="center">Invite team members</Typography>
<Typography variant="paragraph">Body text here.</Typography>
<Typography variant="paragraph-medium">Emphasized text.</Typography>
// ❌ Wrong — no raw Tailwind for text
<p className="text-[30px] font-semibold">Invite team members</p>
<h2 className="text-3xl font-semibold">Heading</h2>
```
### How to get the Figma style and map to Typography
1. **Get style from node**: Call `get_variable_defs(fileKey, nodeId)` — returns variables used by that text node.
2. **Extract style name**: Look for keys whose value is `Font(...)` — that key is the Figma style name.
- Example: `"heading 2": "Font(family: ...)"` → style name is `heading 2`
- Example: `"paragraph small/medium": "Font(...)"` → style name is `paragraph small/medium`
3. **Map to variant**: Use the table below.
### Figma style name → Typography variant mapping
| Figma variable / style | Typography variant |
| ------------------------ | ---------------------------------- |
| heading 1 | `variant="h1"` |
| heading 2 | `variant="h2"` |
| heading 3 | `variant="h3"` |
| heading 4 | `variant="h4"` |
| paragraph/regular | `variant="paragraph"` |
| paragraph/medium | `variant="paragraph-medium"` |
| paragraph/bold | `variant="paragraph-bold"` |
| paragraph/small | `variant="paragraph-small"` |
| paragraph/small + medium | `variant="paragraph-small-medium"` |
| paragraph/mini | `variant="paragraph-mini"` |
| paragraph/mini + medium | `variant="paragraph-mini-medium"` |
| paragraph/mini + bold | `variant="paragraph-mini-bold"` |
| monospaced | `variant="code"` |
**Align:** Use `align="center"` or `align="right"` when the design has centered/right-aligned text.
**Color:** Use `color="muted"` for muted/secondary text (e.g. `text-muted-foreground`).
## Component Mappings
| Figma Element | shadcn Component |
+11
View File
@@ -0,0 +1,11 @@
# CodeScene Configuration
# https://docs.enterprise.codescene.io/latest/configuration/code-health-configuration.html
version: 2
# Exclude paths from code health analysis
exclude:
- pattern: 'apps/web/src/components/ui/**'
reason: 'shadcn UI components are semi-auto generated from templates'
- pattern: 'apps/web/src/components/ui/stories/**'
reason: 'Storybook stories for shadcn UI components'
@@ -0,0 +1,42 @@
import { render, fireEvent } from '@/src/tests/test-utils'
import { ErrorWidget } from './ErrorWidget'
jest.mock('@/src/components/SafeFontIcon', () => ({
SafeFontIcon: () => 'MockSafeFontIcon',
}))
describe('ErrorWidget', () => {
it('renders the default error message and subtitle', () => {
const { getByText } = render(<ErrorWidget />)
expect(getByText('Unable to load content')).toBeTruthy()
expect(getByText('Try to reload the page.')).toBeTruthy()
})
it('renders a custom error message', () => {
const { getByText } = render(<ErrorWidget message="Something went wrong" />)
expect(getByText('Something went wrong')).toBeTruthy()
})
it('renders the reload button when onRefresh is provided', () => {
const { getByText } = render(<ErrorWidget onRefresh={jest.fn()} />)
expect(getByText('Reload page')).toBeTruthy()
})
it('does not render the reload button when onRefresh is not provided', () => {
const { queryByText } = render(<ErrorWidget />)
expect(queryByText('Reload page')).toBeNull()
})
it('calls onRefresh when the reload button is pressed', () => {
const onRefresh = jest.fn()
const { getByText } = render(<ErrorWidget onRefresh={onRefresh} />)
fireEvent.press(getByText('Reload page'))
expect(onRefresh).toHaveBeenCalledTimes(1)
})
})
@@ -1,16 +1,41 @@
import React from 'react'
import { Text } from 'tamagui'
import { Text, Button, Stack } from 'tamagui'
import { SafeFontIcon } from '@/src/components/SafeFontIcon'
import { WidgetDisplayWrapper } from '../WidgetDisplayWrapper'
export function ErrorWidget() {
interface ErrorWidgetProps {
message?: string
onRefresh?: () => void
}
export function ErrorWidget({ message = 'Unable to load content', onRefresh }: ErrorWidgetProps) {
return (
<WidgetDisplayWrapper gap="$0">
<Text color="$colorSecondary" fontSize="$2" textAlign="center">
Transaction checks are temporarily unavailable.
</Text>
<Text color="$colorSecondary" fontSize="$2" textAlign="center">
Please try again later.
</Text>
<WidgetDisplayWrapper gap="$5" alignItems="center" paddingVertical="$4">
<Stack
backgroundColor="$backgroundPress"
borderRadius="$2"
width={40}
height={40}
alignItems="center"
justifyContent="center"
>
<SafeFontIcon name="alert-circle-filled" size={24} color="$colorSecondary" />
</Stack>
<Stack gap="$1" alignItems="center">
<Text color="$colorSecondary" fontSize="$4" fontWeight="600" textAlign="center">
{message}
</Text>
<Text color="$colorSecondary" fontSize="$3" textAlign="center">
Try to reload the page.
</Text>
</Stack>
{onRefresh && (
<Button size="$3" onPress={onRefresh}>
<Button.Text>Reload page</Button.Text>
</Button>
)}
</WidgetDisplayWrapper>
)
}
+3 -3
View File
@@ -8,8 +8,7 @@ import createEmotionCache from '../src/utils/createEmotionCache'
import { initialize, mswLoader } from 'msw-storybook-addon'
import '../src/styles/globals.css'
import '../src/styles/shadcn.css'
import { ShadcnProvider } from '../src/components/ui/ShadcnProvider'
import { ShadcnProvider } from '../.storybook/shadcn'
// Create emotion cache once for Storybook (same as real app)
// This ensures MUI styles are injected first, allowing CSS modules to override them
@@ -148,7 +147,8 @@ const preview: Preview = {
loaders: [mswLoader],
decorators: [
// Conditional MUI/shadcn decorator: skip MUI for UI/ stories, wrap with ShadcnProvider instead
// UI/ stories get ShadcnProvider only (no MUI). All other stories get MUI only.
// Stories that need shadcn opt in via `shadcn: true` on withMockProvider/createMockStory.
(Story, context) => {
const themeMode = (context.globals?.theme as 'light' | 'dark') || 'light'
@@ -2,6 +2,7 @@ import React, { type ReactNode } from 'react'
import { StoreDecorator } from '@/stories/storeDecorator'
import type { StoryContext } from 'storybook/internal/csf'
import { Paper } from '@mui/material'
import { ShadcnProvider } from '@/components/ui/ShadcnProvider'
type MockProviderDecoratorProps = {
children: ReactNode
@@ -13,6 +14,8 @@ type MockProviderDecoratorProps = {
withPaper?: boolean
/** Paper padding */
paperPadding?: number
/** Wrap with ShadcnProvider for shadcn component support */
shadcn?: boolean
}
/**
@@ -28,14 +31,22 @@ export const MockProviderDecorator = ({
context,
withPaper = false,
paperPadding = 2,
shadcn = false,
}: MockProviderDecoratorProps) => {
const content = withPaper ? <Paper sx={{ p: paperPadding }}>{children}</Paper> : children
return (
const wrapped = (
<StoreDecorator initialState={initialState} context={context}>
{content}
</StoreDecorator>
)
if (shadcn) {
const isDark = (context?.globals as Record<string, unknown>)?.theme === 'dark'
return <ShadcnProvider dark={isDark}>{wrapped}</ShadcnProvider>
}
return wrapped
}
/**
-3
View File
@@ -37,6 +37,3 @@ export function stringify(obj: Record<string, unknown>): string {
return params.toString()
}
const querystring = { parse, stringify }
export default querystring
+3 -4
View File
@@ -8,9 +8,7 @@ import createEmotionCache from '../src/utils/createEmotionCache'
import { initialize, mswLoader } from 'msw-storybook-addon'
import '../src/styles/globals.css'
import '../src/styles/shadcn.css'
import './shadcn-stories.css'
import { ShadcnProvider } from '../src/components/ui/ShadcnProvider'
import { ShadcnProvider } from './shadcn'
// Create emotion cache once for Storybook (same as real app)
// This ensures MUI styles are injected first, allowing CSS modules to override them
@@ -149,7 +147,8 @@ const preview: Preview = {
loaders: [mswLoader],
decorators: [
// Conditional MUI/shadcn decorator: skip MUI for UI/ stories, wrap with ShadcnProvider instead
// UI/ stories get ShadcnProvider only (no MUI). All other stories get MUI only.
// Stories that need shadcn opt in via `shadcn: true` on withMockProvider/createMockStory.
(Story, context) => {
const themeMode = (context.globals?.theme as 'light' | 'dark') || 'light'
+13
View File
@@ -0,0 +1,13 @@
/**
* Storybook-specific ShadcnProvider that bundles the CSS dependency.
*
* In production, shadcn.css is imported from _app.tsx. In Storybook, _app.tsx is
* not part of the bundle, so we import the CSS here alongside the provider.
*
* NOTE: The CSS is global once loaded (webpack side-effect), but all styles are
* scoped to .shadcn-scope via selectors, so they won't affect MUI-only stories.
*/
import '../src/styles/shadcn.css'
import './shadcn-stories.css'
export { ShadcnProvider } from '../src/components/ui/ShadcnProvider'
+2 -1
View File
@@ -2,7 +2,7 @@
> **Maintenance:** This file is manually maintained. Update it when adding, removing, or changing visual test files in `e2e/visual/`. Cross-reference with `.storybook/COVERAGE.md` for component-level gaps.
**64 tests** across **33 test files** — each test captures an Argos screenshot automatically.
**65 tests** across **33 test files** — each test captures an Argos screenshot automatically.
Screenshots are captured via a global `afterEach` hook in `cypress/support/e2e.js`.
@@ -47,6 +47,7 @@ Screenshots are captured via a global `afterEach` hook in `cypress/support/e2e.j
| `/imprint` | legal_pages.cy.js | 1 | No | No |
| `/cookie` | legal_pages.cy.js | 1 | No | No |
| `/safe-labs-terms` | legal_pages.cy.js | 1 | No | No |
| `/welcome/spaces` | spaces.cy.js | 1 | Yes (auth + API) | No |
| `/spaces` (dashboard) | spaces.cy.js | 1 | Yes (auth + API) | No |
| `/spaces/settings` | spaces.cy.js | 1 | Yes (auth + API) | No |
| `/spaces/members` | spaces.cy.js | 1 | Yes (auth + API) | No |
-1
View File
@@ -15,7 +15,6 @@ describe('[VISUAL] Bridge page screenshots', { defaultCommandTimeout: 60000, ...
})
it('[VISUAL] Screenshot bridge page', () => {
main.enableChainFeature(constants.chainFeatures.bridge)
cy.visit(constants.bridgeUrl + staticSafes.SEP_STATIC_SAFE_2)
main.awaitVisualStability()
})
-1
View File
@@ -15,7 +15,6 @@ describe('[VISUAL] Earn page screenshots', { defaultCommandTimeout: 60000, ...co
})
it('[VISUAL] Screenshot earn page', () => {
main.enableChainFeature(constants.chainFeatures.earn)
cy.visit(constants.earnUrl + staticSafes.SEP_STATIC_SAFE_2)
main.awaitVisualStability()
})
@@ -15,7 +15,6 @@ describe('[VISUAL] Positions page screenshots', { defaultCommandTimeout: 60000,
})
it('[VISUAL] Screenshot DeFi positions page', () => {
main.enableChainFeature(constants.chainFeatures.positions)
cy.visit(constants.positionsUrl + staticSafes.SEP_STATIC_SAFE_2)
main.awaitVisualStability()
})
+8 -1
View File
@@ -5,7 +5,9 @@ import { mockVisualTestApis } from '../../support/visual-mocks.js'
const SPACE_ID = '1'
function setupSpacesAuth() {
main.enableChainFeature(constants.chainFeatures.spaces)
// Note: SPACES feature flag is already present in the chains fixture (all.json).
// Do NOT call enableChainFeature here — it uses req.continue() which bypasses
// the fixture mock and hits the real staging server, causing flaky failures.
main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__auth, {
sessionExpiresAt: Date.now() + 24 * 60 * 60 * 1000,
@@ -34,6 +36,11 @@ describe('[VISUAL] Spaces page screenshots', { defaultCommandTimeout: 60000, ...
setupSpacesAuth()
})
it('[VISUAL] Screenshot spaces welcome page', () => {
cy.visit(constants.spacesUrl)
main.awaitVisualStability()
})
it('[VISUAL] Screenshot spaces dashboard page', () => {
cy.visit(constants.spaceDashboardUrl + SPACE_ID)
main.awaitVisualStability()
-1
View File
@@ -15,7 +15,6 @@ describe('[VISUAL] Stake page screenshots', { defaultCommandTimeout: 60000, ...c
})
it('[VISUAL] Screenshot stake page', () => {
main.enableChainFeature(constants.chainFeatures.staking)
cy.visit(constants.stakingUrl + staticSafes.SEP_STATIC_SAFE_2)
main.awaitVisualStability()
})
-1
View File
@@ -15,7 +15,6 @@ describe('[VISUAL] Swap page screenshots', { defaultCommandTimeout: 60000, ...co
})
it('[VISUAL] Screenshot swap page', () => {
main.enableChainFeature(constants.chainFeatures.nativeSwaps)
cy.visit(constants.swapUrl + staticSafes.SEP_STATIC_SAFE_2)
main.awaitVisualStability()
})
+4 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": ["src/pages/**/*.tsx", "src/pages/**/*.ts", "src/app/**/*.tsx", "src/app/**/*.ts"],
"entry": ["src/pages/**/*.tsx", "src/features/**/feature.ts"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": [
"**/*.test.{ts,tsx}",
@@ -13,7 +13,9 @@
"src/features/counterfactual/contract.ts",
"src/features/ledger/types.ts",
"src/features/multichain/types.ts",
"src/store/**/*Slice.ts"
"src/store/**/*Slice.ts",
"src/components/ui/**",
".storybook/mocks/querystring.ts"
],
"ignoreDependencies": [
"@storybook/*",
+1
View File
@@ -105,6 +105,7 @@
"lodash": "^4.17.23",
"lucide-react": "^0.563.0",
"mixpanel-browser": "^2.66.0",
"motion": "^12.34.0",
"next": "patch:next@15.5.8#../../.yarn/patches/next-npm-15.5.8-7d525d02b9.patch",
"next-themes": "^0.4.6",
"papaparse": "^5.3.2",
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.46973 8.46973C9.76262 8.17683 10.2374 8.17683 10.5303 8.46973C10.8232 8.76262 10.8232 9.23738 10.5303 9.53027L8.06055 12L10.5303 14.4697C10.8232 14.7626 10.8232 15.2374 10.5303 15.5303C10.2374 15.8232 9.76262 15.8232 9.46973 15.5303L6.46973 12.5303C6.17683 12.2374 6.17683 11.7626 6.46973 11.4697L9.46973 8.46973Z" fill="#0A0A0A"/>
<path d="M13.4697 8.46973C13.7626 8.17683 14.2374 8.17683 14.5303 8.46973L17.5303 11.4697C17.8232 11.7626 17.8232 12.2374 17.5303 12.5303L14.5303 15.5303C14.2374 15.8232 13.7626 15.8232 13.4697 15.5303C13.1768 15.2374 13.1768 14.7626 13.4697 14.4697L15.9395 12L13.4697 9.53027C13.1768 9.23738 13.1768 8.76262 13.4697 8.46973Z" fill="#0A0A0A"/>
<path d="M20.25 5C20.25 4.30964 19.6904 3.75 19 3.75H5C4.30964 3.75 3.75 4.30964 3.75 5V19C3.75 19.6904 4.30964 20.25 5 20.25H19C19.6904 20.25 20.25 19.6904 20.25 19V5ZM21.75 19C21.75 20.5188 20.5188 21.75 19 21.75H5C3.48122 21.75 2.25 20.5188 2.25 19V5C2.25 3.48122 3.48122 2.25 5 2.25H19C20.5188 2.25 21.75 3.48122 21.75 5V19Z" fill="#0A0A0A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react'
import BackLink from './index'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
const meta = {
component: BackLink,
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 pt-4 pb-0">
<Story />
</div>
),
],
tags: ['autodocs'],
} satisfies Meta<typeof BackLink>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
onClick: () => console.log('back clicked'),
children: (
<Avatar className="size-8 shrink-0">
<AvatarFallback
className="rounded-md text-primary-foreground text-sm font-semibold"
style={{ backgroundColor: 'hsl(137, 55%, 55%)' }}
>
A
</AvatarFallback>
</Avatar>
),
},
}
@@ -0,0 +1,39 @@
import { render, screen, fireEvent } from '@testing-library/react'
import BackLink from './index'
describe('BackLink', () => {
it('renders children and chevron icon', () => {
render(
<BackLink onClick={jest.fn()}>
<span data-testid="child">A</span>
</BackLink>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
expect(screen.getByLabelText('Go back')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(
<BackLink onClick={handleClick}>
<span>A</span>
</BackLink>,
)
fireEvent.click(screen.getByLabelText('Go back'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('renders as a button with correct aria-label', () => {
render(
<BackLink onClick={jest.fn()}>
<span>A</span>
</BackLink>,
)
const button = screen.getByRole('button', { name: 'Go back' })
expect(button).toBeInTheDocument()
expect(button.tagName).toBe('BUTTON')
})
})
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { ChevronLeft } from 'lucide-react'
type BackLinkProps = {
children: ReactNode
onClick: () => void
ariaLabel?: string
}
function BackLink({ children, onClick, ariaLabel = 'Go back' }: BackLinkProps) {
return (
// TODO: change rounded-lg (8px) to rounded-2xl (16px) after migrating to the new design system
<div className="flex self-stretch rounded-lg bg-card shadow-[0px_4px_20px_0px_rgba(0,0,0,0.03)]">
<button
onClick={onClick}
className="flex flex-1 items-center gap-1 min-h-[60px] border-0 rounded-lg bg-transparent pl-2 pr-2 m-1 cursor-pointer hover:bg-muted/30 transition-colors"
aria-label={ariaLabel}
>
<ChevronLeft className="size-5" />
{children}
</button>
</div>
)
}
export default BackLink
@@ -17,6 +17,7 @@ exports[`./ChainIndicator.stories Default 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
<div
@@ -61,6 +62,7 @@ exports[`./ChainIndicator.stories Inline 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
<div
@@ -94,6 +96,7 @@ exports[`./ChainIndicator.stories LargeImage 1`] = `
height="36"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 36px;"
width="36"
/>
<div
@@ -154,6 +157,7 @@ exports[`./ChainIndicator.stories OnlyLogo 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -178,6 +182,7 @@ exports[`./ChainIndicator.stories Responsive 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
<div
@@ -211,6 +216,7 @@ exports[`./ChainIndicator.stories SmallImage 1`] = `
height="16"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 16px;"
width="16"
/>
<div
@@ -275,6 +281,7 @@ exports[`./ChainIndicator.stories WithFiatValue 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
<div
@@ -65,6 +65,7 @@ const ChainIndicator = ({
width={imageSize}
height={imageSize}
loading="lazy"
style={{ minWidth: imageSize }}
/>
) : (
<SvgIcon
@@ -3,6 +3,7 @@ import EthHashInfo from './index'
import { Paper } from '@mui/material'
import { StoreDecorator } from '@/stories/storeDecorator'
import { RouterDecorator } from '@/stories/routerDecorator'
const meta = {
component: EthHashInfo,
@@ -14,9 +15,11 @@ const meta = {
(Story) => {
return (
<StoreDecorator initialState={{}}>
<Paper sx={{ padding: 2 }}>
<Story />
</Paper>
<RouterDecorator>
<Paper sx={{ padding: 2 }}>
<Story />
</Paper>
</RouterDecorator>
</StoreDecorator>
)
},
@@ -0,0 +1,128 @@
import { useRef } from 'react'
import { render, screen, fireEvent } from '@/tests/test-utils'
import NotificationsPopover, { type NotificationsPopoverRef } from './NotificationsPopover'
import type { Notification } from '@/store/notificationsSlice'
import type { RootState } from '@/store'
jest.mock('@/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage', () => ({
useShowNotificationsRenewalMessage: jest.fn(),
}))
jest.mock('@/services/analytics', () => ({
trackEvent: jest.fn(),
OVERVIEW_EVENTS: { NOTIFICATION_CENTER: 'notification_center' },
}))
jest.mock(
'@/components/notification-center/NotificationCenterList',
() =>
function NotificationCenterList() {
return <div>NotificationCenterList</div>
},
)
jest.mock('@/hooks/useChains', () => ({
useHasFeature: jest.fn().mockReturnValue(false),
}))
const createNotification = (overrides: Partial<Notification> = {}): Notification => ({
id: Math.random().toString(32).slice(2),
message: 'Test notification',
groupKey: 'test',
variant: 'info',
timestamp: Date.now(),
isRead: false,
isDismissed: false,
...overrides,
})
// Wrapper that opens the popover via the imperative ref
function PopoverOpener() {
const ref = useRef<NotificationsPopoverRef>(null)
return (
<>
<button data-testid="open-trigger" onClick={(e) => ref.current?.handleClick(e as any)} />
<NotificationsPopover ref={ref} />
</>
)
}
describe('NotificationsPopover', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders data-testid="notifications-title" when open', () => {
const initialReduxState: Partial<RootState> = {
notifications: [createNotification()],
}
render(<PopoverOpener />, { initialReduxState })
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.getByTestId('notifications-title')).toBeInTheDocument()
expect(screen.getByTestId('notifications-title')).toHaveTextContent('Notifications')
})
it('renders "Clear all" when there are notifications', () => {
const initialReduxState: Partial<RootState> = {
notifications: [createNotification()],
}
render(<PopoverOpener />, { initialReduxState })
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.getByText('Clear all')).toBeInTheDocument()
})
it('does not render "Clear all" when there are no notifications', () => {
render(<PopoverOpener />)
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.queryByText('Clear all')).not.toBeInTheDocument()
})
it('renders data-testid="notifications-button" when push notifications feature is enabled', () => {
const { useHasFeature } = require('@/hooks/useChains')
useHasFeature.mockReturnValue(true)
render(<PopoverOpener />)
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.getByTestId('notifications-button')).toBeInTheDocument()
expect(screen.getByTestId('notifications-button')).toHaveTextContent('Push notifications settings')
})
it('does not render push notifications settings when feature is disabled', () => {
const { useHasFeature } = require('@/hooks/useChains')
useHasFeature.mockReturnValue(false)
render(<PopoverOpener />)
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.queryByTestId('notifications-button')).not.toBeInTheDocument()
})
it('shows unread count when there are unread notifications', () => {
const initialReduxState: Partial<RootState> = {
notifications: [createNotification(), createNotification()],
}
render(<PopoverOpener />, { initialReduxState })
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.getByText('2')).toBeInTheDocument()
})
it('does not show unread count when all notifications are read', () => {
const initialReduxState: Partial<RootState> = {
notifications: [createNotification({ isRead: true })],
}
render(<PopoverOpener />, { initialReduxState })
fireEvent.click(screen.getByTestId('open-trigger'))
expect(screen.queryByText('1')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,129 @@
import { forwardRef, useImperativeHandle, type MouseEvent, type ReactElement } from 'react'
import Popover from '@mui/material/Popover'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import IconButton from '@mui/material/IconButton'
import MuiLink from '@mui/material/Link'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SvgIcon from '@mui/material/SvgIcon'
import Link from 'next/link'
import { useRouter } from 'next/router'
import NotificationCenterList from '@/components/notification-center/NotificationCenterList'
import UnreadBadge from '@/components/common/UnreadBadge'
import notificationCss from '@/components/notification-center/NotificationCenter/styles.module.css'
import { useHasFeature } from '@/hooks/useChains'
import { FEATURES } from '@safe-global/utils/utils/chains'
import { AppRoutes } from '@/config/routes'
import SettingsIcon from '@/public/images/sidebar/settings.svg'
import useNotificationsPopover, { NOTIFICATION_CENTER_LIMIT } from './hooks/useNotificationsPopover'
export type NotificationsPopoverRef = {
handleClick: (event: MouseEvent<HTMLButtonElement>) => void
}
const NotificationsPopover = forwardRef<NotificationsPopoverRef>((_props, ref): ReactElement => {
const router = useRouter()
const hasPushNotifications = useHasFeature(FEATURES.PUSH_NOTIFICATIONS)
const {
notifications,
notificationsToShow,
unreadCount,
open,
anchorEl,
showAll,
setShowAll,
canExpand,
handleClick,
handleClose,
handleClear,
} = useNotificationsPopover()
useImperativeHandle(ref, () => ({
handleClick,
}))
const ExpandIcon = showAll ? ExpandLessIcon : ExpandMoreIcon
const onSettingsClick = () => {
setTimeout(handleClose, 300)
}
return (
<Popover
key={Number(open)}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
sx={{ mt: 1 }}
transitionDuration={0}
>
<Paper className={notificationCss.popoverContainer}>
<div className={notificationCss.popoverHeader}>
<div>
<Typography data-testid="notifications-title" variant="h4" component="span" fontWeight={700}>
Notifications
</Typography>
{unreadCount > 0 && (
<Typography variant="caption" className={notificationCss.unreadCount}>
{unreadCount}
</Typography>
)}
</div>
{notifications.length > 0 && (
<MuiLink onClick={handleClear} variant="body2" component="button" sx={{ textDecoration: 'unset' }}>
Clear all
</MuiLink>
)}
</div>
<div>
<NotificationCenterList notifications={notificationsToShow} handleClose={handleClose} />
</div>
<div className={notificationCss.popoverFooter}>
{canExpand && (
<>
<IconButton
onClick={() => setShowAll((prev) => !prev)}
disableRipple
className={notificationCss.expandButton}
>
<UnreadBadge
invisible={showAll || unreadCount <= NOTIFICATION_CENTER_LIMIT}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<ExpandIcon color="border" />
</UnreadBadge>
</IconButton>
<Typography sx={{ color: ({ palette }) => palette.border.main }}>
{showAll ? 'Hide' : `${notifications.length - NOTIFICATION_CENTER_LIMIT} other notifications`}
</Typography>
</>
)}
{hasPushNotifications && (
<Link href={{ pathname: AppRoutes.settings.notifications, query: router.query }} passHref legacyBehavior>
<MuiLink
data-testid="notifications-button"
className={notificationCss.settingsLink}
variant="body2"
onClick={onSettingsClick}
>
<SvgIcon component={SettingsIcon} inheritViewBox fontSize="small" /> Push notifications settings
</MuiLink>
</Link>
)}
</div>
</Paper>
</Popover>
)
})
NotificationsPopover.displayName = 'NotificationsPopover'
export default NotificationsPopover
@@ -0,0 +1,98 @@
import { renderHook, act } from '@/tests/test-utils'
import useNotificationsPopover, { NOTIFICATION_CENTER_LIMIT } from './useNotificationsPopover'
import type { Notification } from '@/store/notificationsSlice'
import type { RootState } from '@/store'
const mockUseShowNotificationsRenewalMessage = jest.fn()
jest.mock('@/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage', () => ({
useShowNotificationsRenewalMessage: () => mockUseShowNotificationsRenewalMessage(),
}))
jest.mock('@/services/analytics', () => ({
trackEvent: jest.fn(),
OVERVIEW_EVENTS: { NOTIFICATION_CENTER: 'notification_center' },
}))
const createNotification = (overrides: Partial<Notification> = {}): Notification => ({
id: Math.random().toString(32).slice(2),
message: 'Test notification',
groupKey: 'test',
variant: 'info',
timestamp: Date.now(),
isRead: false,
isDismissed: false,
...overrides,
})
const stateWithNotifications = (notifications: Notification[]): Partial<RootState> => ({
notifications,
})
describe('useNotificationsPopover', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('calls useShowNotificationsRenewalMessage on render', () => {
renderHook(() => useNotificationsPopover())
expect(mockUseShowNotificationsRenewalMessage).toHaveBeenCalled()
})
it('returns empty notifications by default', () => {
const { result } = renderHook(() => useNotificationsPopover())
expect(result.current.notifications).toEqual([])
expect(result.current.unreadCount).toBe(0)
expect(result.current.open).toBe(false)
expect(result.current.canExpand).toBe(false)
})
it('computes unreadCount from notifications', () => {
const initialReduxState = stateWithNotifications([
createNotification(),
createNotification(),
createNotification({ isRead: true }),
])
const { result } = renderHook(() => useNotificationsPopover(), { initialReduxState })
expect(result.current.unreadCount).toBe(2)
})
it('sorts notifications chronologically (newest first)', () => {
const initialReduxState = stateWithNotifications([
createNotification({ message: 'older', timestamp: 1000 }),
createNotification({ message: 'newer', timestamp: 2000 }),
])
const { result } = renderHook(() => useNotificationsPopover(), { initialReduxState })
expect(result.current.notificationsToShow[0].message).toBe('newer')
expect(result.current.notificationsToShow[1].message).toBe('older')
})
it('limits visible notifications when canExpand is true', () => {
const notifications = Array.from({ length: NOTIFICATION_CENTER_LIMIT + 2 }, () => createNotification())
const initialReduxState = stateWithNotifications(notifications)
const { result } = renderHook(() => useNotificationsPopover(), { initialReduxState })
expect(result.current.canExpand).toBe(true)
expect(result.current.notificationsToShow).toHaveLength(NOTIFICATION_CENTER_LIMIT)
})
it('clears all notifications on handleClear', () => {
const initialReduxState = stateWithNotifications([createNotification()])
const { result } = renderHook(() => useNotificationsPopover(), { initialReduxState })
expect(result.current.notifications).toHaveLength(1)
act(() => {
result.current.handleClear()
})
expect(result.current.notifications).toHaveLength(0)
})
})
@@ -0,0 +1,85 @@
import { useMemo, useState, type MouseEvent } from 'react'
import { useAppDispatch, useAppSelector } from '@/store'
import {
selectNotifications,
readNotification,
closeNotification,
deleteAllNotifications,
} from '@/store/notificationsSlice'
import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics'
import { useShowNotificationsRenewalMessage } from '@/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage'
export const NOTIFICATION_CENTER_LIMIT = 4
const useNotificationsPopover = () => {
useShowNotificationsRenewalMessage()
const dispatch = useAppDispatch()
const notifications = useAppSelector(selectNotifications)
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
const [showAll, setShowAll] = useState<boolean>(false)
const open = Boolean(anchorEl)
const chronologicalNotifications = useMemo(() => {
return notifications.slice().sort((a, b) => b.timestamp - a.timestamp)
}, [notifications])
const canExpand = notifications.length > NOTIFICATION_CENTER_LIMIT + 1
const notificationsToShow =
showAll || !canExpand ? chronologicalNotifications : chronologicalNotifications.slice(0, NOTIFICATION_CENTER_LIMIT)
const unreadCount = useMemo(() => notifications.filter(({ isRead }) => !isRead).length, [notifications])
const handleRead = () => {
notificationsToShow.forEach(({ isRead, id }) => {
if (!isRead) {
dispatch(readNotification({ id }))
}
})
setShowAll(false)
}
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (!open) {
trackEvent(OVERVIEW_EVENTS.NOTIFICATION_CENTER)
notifications.forEach(({ isDismissed, id }) => {
if (!isDismissed) {
dispatch(closeNotification({ id }))
}
})
} else {
handleRead()
}
setAnchorEl(open ? null : event.currentTarget)
}
const handleClose = () => {
if (open) {
handleRead()
setShowAll(false)
}
setAnchorEl(null)
}
const handleClear = () => {
dispatch(deleteAllNotifications())
}
return {
notifications,
notificationsToShow,
unreadCount,
open,
anchorEl,
showAll,
setShowAll,
canExpand,
handleClick,
handleClose,
handleClear,
}
}
export default useNotificationsPopover
@@ -0,0 +1,202 @@
import Topbar from './index'
import * as contracts from '@/features/__core__'
import { render, screen } from '@/tests/test-utils'
import userEvent from '@testing-library/user-event'
import type { Notification } from '@/store/notificationsSlice'
import type { RootState } from '@/store'
import { trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
jest.mock('@/features/__core__', () => ({
...jest.requireActual('@/features/__core__'),
useLoadFeature: jest.fn(),
}))
const mockWallet = { address: '0x1234567890abcdef1234567890abcdef12345678', balance: '0' }
const mockUseIsMobile = jest.fn(() => false)
jest.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => mockUseIsMobile(),
}))
jest.mock('@/features/wallet', () => ({
WalletFeature: { name: 'wallet' },
useWalletPopover: () => ({
wallet: mockWallet,
open: false,
anchorEl: null,
handleClick: jest.fn(),
handleClose: jest.fn(),
}),
}))
jest.mock('@/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage', () => ({
useShowNotificationsRenewalMessage: jest.fn(),
}))
jest.mock('@/services/analytics', () => ({
trackEvent: jest.fn(),
OVERVIEW_EVENTS: { NOTIFICATION_CENTER: 'notification_center' },
}))
jest.mock('@/services/analytics/events/spaces', () => ({
SPACE_EVENTS: {
WALLET_SWITCHED: { action: 'wallet_switched', category: 'spaces' },
WALLET_DISCONNECTED: { action: 'wallet_disconnected', category: 'spaces' },
},
}))
const mockUseCurrentSpaceId = jest.fn<string | null, []>(() => 'space-42')
jest.mock('@/features/spaces', () => ({
useCurrentSpaceId: () => mockUseCurrentSpaceId(),
}))
jest.mock(
'@/components/notification-center/NotificationCenterList',
() =>
function NotificationCenterList() {
return <div>NotificationCenterList</div>
},
)
const createNotification = (overrides: Partial<Notification> = {}): Notification => ({
id: Math.random().toString(32).slice(2),
message: 'Test notification',
groupKey: 'test',
variant: 'info',
timestamp: Date.now(),
isRead: false,
isDismissed: false,
...overrides,
})
const mockUseLoadFeature = contracts.useLoadFeature as jest.Mock
describe('Topbar', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseIsMobile.mockReturnValue(false)
mockUseLoadFeature.mockReturnValue({
WalletPopover: () => null,
})
})
it('renders the wallet address in HeaderNavigation', () => {
render(<Topbar />)
expect(screen.getByText('0x1234...5678')).toBeInTheDocument()
})
it('renders notification badge when there are unread notifications', () => {
const initialReduxState: Partial<RootState> = {
notifications: [createNotification(), createNotification()],
}
render(<Topbar />, { initialReduxState })
expect(screen.getByLabelText('2 unread messages')).toBeInTheDocument()
})
it('does not render notification badge when there are no unread notifications', () => {
render(<Topbar />)
expect(screen.queryByLabelText(/unread messages/)).not.toBeInTheDocument()
})
it('does not count read notifications in the badge', () => {
const initialReduxState: Partial<RootState> = {
notifications: [createNotification({ isRead: true }), createNotification()],
}
render(<Topbar />, { initialReduxState })
expect(screen.getByLabelText('1 unread messages')).toBeInTheDocument()
})
describe('wallet tracking', () => {
beforeEach(() => {
mockUseCurrentSpaceId.mockReturnValue('space-42')
mockUseLoadFeature.mockReturnValue({
WalletPopover: ({
onWalletSwitch,
onWalletDisconnect,
}: {
onWalletSwitch?: () => void
onWalletDisconnect?: () => void
}) => (
<>
<button onClick={onWalletSwitch}>trigger-switch</button>
<button onClick={onWalletDisconnect}>trigger-disconnect</button>
</>
),
})
})
it('fires WALLET_SWITCHED with spaceId as GA label and Mixpanel param', () => {
render(<Topbar />)
screen.getByText('trigger-switch').click()
expect(trackEvent).toHaveBeenCalledWith(
{ ...SPACE_EVENTS.WALLET_SWITCHED, label: 'space-42' },
{ spaceId: 'space-42' },
)
})
it('fires WALLET_DISCONNECTED with spaceId as GA label and Mixpanel param', () => {
render(<Topbar />)
screen.getByText('trigger-disconnect').click()
expect(trackEvent).toHaveBeenCalledWith(
{ ...SPACE_EVENTS.WALLET_DISCONNECTED, label: 'space-42' },
{ spaceId: 'space-42' },
)
})
it('fires WALLET_SWITCHED exactly once per click', () => {
render(<Topbar />)
screen.getByText('trigger-switch').click()
expect(trackEvent).toHaveBeenCalledTimes(1)
})
it('fires WALLET_DISCONNECTED exactly once per click', () => {
render(<Topbar />)
screen.getByText('trigger-disconnect').click()
expect(trackEvent).toHaveBeenCalledTimes(1)
})
it('does not fire when spaceId is null (outside Spaces)', () => {
mockUseCurrentSpaceId.mockReturnValue(null)
render(<Topbar />)
screen.getByText('trigger-switch').click()
screen.getByText('trigger-disconnect').click()
expect(trackEvent).not.toHaveBeenCalled()
})
})
describe('mobile', () => {
beforeEach(() => {
mockUseIsMobile.mockReturnValue(true)
})
it('shows the sidebar menu button when on mobile and onMenuToggle is provided', () => {
const onMenuToggle = jest.fn()
render(<Topbar onMenuToggle={onMenuToggle} />)
expect(screen.getByRole('button', { name: /open sidebar menu/i })).toBeInTheDocument()
})
it('calls onMenuToggle with a toggle function when the menu button is clicked', async () => {
const user = userEvent.setup()
const onMenuToggle = jest.fn()
render(<Topbar onMenuToggle={onMenuToggle} />)
await user.click(screen.getByRole('button', { name: /open sidebar menu/i }))
expect(onMenuToggle).toHaveBeenCalledTimes(1)
const setStateArg = onMenuToggle.mock.calls[0][0] as (prev: boolean) => boolean
expect(typeof setStateArg).toBe('function')
expect(setStateArg(false)).toBe(true)
expect(setStateArg(true)).toBe(false)
})
})
})
@@ -0,0 +1,87 @@
import type { Dispatch, SetStateAction } from 'react'
import { useMemo, useRef, type ReactElement } from 'react'
import { Menu } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { HeaderNavigation } from '@/features/spaces/components/HeaderNavigation'
import { useLoadFeature } from '@/features/__core__'
import { WalletFeature, useWalletPopover } from '@/features/wallet'
import { useIsMobile } from '@/hooks/use-mobile'
import { useAppSelector } from '@/store'
import { selectNotifications } from '@/store/notificationsSlice'
import NotificationsPopover, { type NotificationsPopoverRef } from './NotificationsPopover'
import { useCurrentSpaceId } from '@/features/spaces'
import { trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
interface TopbarProps {
/** When provided, shows a menu button on mobile to open the sidebar */
onMenuToggle?: Dispatch<SetStateAction<boolean>>
}
const Topbar = ({ onMenuToggle }: TopbarProps): ReactElement => {
const isMobile = useIsMobile()
const {
wallet,
open: walletOpen,
anchorEl: walletAnchorEl,
handleClick: handleWalletClick,
handleClose: handleWalletClose,
} = useWalletPopover()
const { WalletPopover } = useLoadFeature(WalletFeature)
const notificationsRef = useRef<NotificationsPopoverRef>(null)
const notifications = useAppSelector(selectNotifications)
const spaceId = useCurrentSpaceId()
const handleWalletSwitch = () => {
if (!spaceId) return
trackEvent({ ...SPACE_EVENTS.WALLET_SWITCHED, label: spaceId }, { spaceId })
}
const handleWalletDisconnect = () => {
if (!spaceId) return
trackEvent({ ...SPACE_EVENTS.WALLET_DISCONNECTED, label: spaceId }, { spaceId })
}
const unreadCount = useMemo(() => notifications.filter(({ isRead }) => !isRead).length, [notifications])
const showMenuButton = Boolean(onMenuToggle && isMobile)
return (
<>
<header
className={`flex items-center p-6 pb-0 bg-secondary -mb-10 dark:bg-background ${showMenuButton ? 'justify-between pl-2' : 'justify-end'}`}
>
{showMenuButton ? (
<Button
variant="ghost"
size="icon"
onClick={() => onMenuToggle?.((open) => !open)}
aria-label="Open sidebar menu"
>
<Menu className="size-5" />
</Button>
) : null}
<HeaderNavigation
walletAddress={wallet?.address ?? ''}
messages={unreadCount}
onNotificationsClick={(e) => notificationsRef.current?.handleClick(e)}
onWalletClick={handleWalletClick}
/>
</header>
<NotificationsPopover ref={notificationsRef} />
{wallet && (
<WalletPopover
wallet={wallet}
open={walletOpen}
anchorEl={walletAnchorEl}
onClose={handleWalletClose}
onWalletSwitch={handleWalletSwitch}
onWalletDisconnect={handleWalletDisconnect}
/>
)}
</>
)
}
export default Topbar
@@ -1,4 +1,4 @@
import Header from '@/components/common/Header/index'
import Header, { getLogoLink } from '@/components/common/Header/index'
import * as useIsSafeOwner from '@/hooks/useIsSafeOwner'
import * as useProposers from '@/hooks/useProposers'
import * as useSafeAddress from '@/hooks/useSafeAddress'
@@ -7,6 +7,7 @@ import * as contracts from '@/features/__core__'
import { render } from '@/tests/test-utils'
import { faker } from '@faker-js/faker'
import { screen, fireEvent } from '@testing-library/react'
import { AppRoutes } from '@/config/routes'
jest.mock(
'@/components/common/SafeTokenWidget',
@@ -35,6 +36,12 @@ jest.mock('@/hooks/useIsOfficialHost', () => ({
const mockUseLoadFeature = contracts.useLoadFeature as jest.Mock
describe('getLogoLink', () => {
it('always redirects to /welcome/accounts', () => {
expect(getLogoLink()).toEqual(AppRoutes.welcome.accounts)
})
})
describe('Header', () => {
beforeEach(() => {
jest.resetAllMocks()
@@ -31,12 +31,8 @@ type HeaderProps = {
onBatchToggle?: Dispatch<SetStateAction<boolean>>
}
export function getLogoLink(router: ReturnType<typeof useRouter>): Url {
return router.pathname === AppRoutes.home || !router.query.safe
? router.pathname === AppRoutes.welcome.accounts
? AppRoutes.welcome.index
: AppRoutes.welcome.accounts
: { pathname: AppRoutes.home, query: { safe: router.query.safe } }
export function getLogoLink(): Url {
return AppRoutes.welcome.accounts
}
const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
@@ -49,8 +45,7 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
const { WalletConnectWidget } = useLoadFeature(WalletConnectFeature)
const isOfficialHost = useIsOfficialHost()
// If on the home page, the logo should link to the Accounts or Welcome page, otherwise to the home page
const logoHref = getLogoLink(router)
const logoHref = getLogoLink()
const handleMenuToggle = () => {
if (onMenuToggle) {
@@ -1,4 +1,5 @@
import SpaceSidebar from 'src/features/spaces/components/SpaceSidebar'
import { SpacesEnhancedSidebar } from '@/features/spaces/components/Sidebar/SpacesEnhancedSidebar'
import Sidebar from '@/components/sidebar/Sidebar'
import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute'
import { useRouter } from 'next/router'
import { useEffect, type ReactElement } from 'react'
@@ -8,7 +9,6 @@ import DoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRightRo
import DoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeftRounded'
import classnames from 'classnames'
import Sidebar from '@/components/sidebar/Sidebar'
import css from './styles.module.css'
import useDebounce from '@safe-global/utils/hooks/useDebounce'
import { useIsSidebarRoute } from '@/hooks/useIsSidebarRoute'
@@ -16,9 +16,10 @@ import { useIsSidebarRoute } from '@/hooks/useIsSidebarRoute'
type SideDrawerProps = {
isOpen: boolean
onToggle: (isOpen: boolean) => void
onSidebarOpenChange?: (open: boolean) => void
}
const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => {
const SideDrawer = ({ isOpen, onToggle, onSidebarOpenChange }: SideDrawerProps): ReactElement => {
const { breakpoints } = useTheme()
const isSmallScreen = useMediaQuery(breakpoints.down('md'))
const [, isSafeAppRoute] = useIsSidebarRoute()
@@ -45,8 +46,6 @@ const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => {
}
}, [onToggle, router, isSmallScreen])
const SidebarComponent = isSpaceRoute ? SpaceSidebar : Sidebar
return (
<>
<Drawer
@@ -58,11 +57,20 @@ const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => {
// fixes a bug on small screens where the drawer is not visible,
// but it steals all the events from the rest of the page
position: 'relative',
...(isSpaceRoute ? { '& .MuiPaper-root': { zIndex: 1250 } } : {}),
}}
className={smDrawerHidden ? css.smDrawerHidden : undefined}
>
<aside>
<SidebarComponent />
{isSpaceRoute ? (
<SpacesEnhancedSidebar
isDrawerOpen={isOpen}
onDrawerClose={() => onToggle(false)}
onOpenChange={onSidebarOpenChange}
/>
) : (
<Sidebar />
)}
</aside>
</Drawer>
@@ -1,7 +1,10 @@
import { useContext, useEffect, useState, type ReactElement } from 'react'
import classnames from 'classnames'
import { AnimatePresence, motion } from 'motion/react'
import Topbar from '@/components/common/Header/Topbar'
import Header from '@/components/common/Header'
import { useIsSpaceRoute } from '@/hooks/useIsSpaceRoute'
import css from './styles.module.css'
import SafeLoadingError from '../SafeLoadingError'
import Footer from '../Footer'
@@ -10,18 +13,40 @@ import { useIsSidebarRoute } from '@/hooks/useIsSidebarRoute'
import { TxModalContext } from '@/components/tx-flow'
import { useLoadFeature } from '@/features/__core__'
import { BatchingFeature } from '@/features/batching'
import Breadcrumbs from '@/components/common/Breadcrumbs'
import { AppRoutes } from '@/config/routes'
import SpaceSafeBar from '@/components/common/SpaceSafeBar'
import { useRouterGuard } from '@/hooks/useRouterGuard'
import { useFlowActivationGuard } from '@/hooks/useRouterGuard/activationGuards/useFlowActivationGuard'
const ONBOARDING_ROUTES = [
AppRoutes.welcome.createSpace,
AppRoutes.welcome.selectSafes,
AppRoutes.welcome.inviteMembers,
]
const NO_HEADER_ROUTES = [
AppRoutes.safeLabsTerms,
AppRoutes.welcome.index,
AppRoutes.welcome.createSpace,
AppRoutes.welcome.selectSafes,
AppRoutes.welcome.inviteMembers,
AppRoutes.spaces.createSpace,
]
const PageLayout = ({ pathname, children }: { pathname: string; children: ReactElement }): ReactElement => {
const [isSidebarRoute, isAnimated] = useIsSidebarRoute(pathname)
const [isSidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [isSpacesSidebarExpanded, setSpacesSidebarExpanded] = useState<boolean>(true)
const [isBatchOpen, setBatchOpen] = useState<boolean>(false)
const { txFlow, setFullWidth } = useContext(TxModalContext)
const { BatchSidebar } = useLoadFeature(BatchingFeature)
const isSafeLabsTermsPage = pathname === AppRoutes.safeLabsTerms
const isWelcomePage = pathname === AppRoutes.welcome.index
const hideHeader = isSafeLabsTermsPage || isWelcomePage
const hideHeader = NO_HEADER_ROUTES.includes(pathname)
const isOnboardingRoute = ONBOARDING_ROUTES.includes(pathname)
const isSpaceRoute = useIsSpaceRoute()
const menuToggleHandler = isSidebarRoute ? setSidebarOpen : undefined
useRouterGuard({ useGuard: useFlowActivationGuard })
// Hide sidebar when transaction flow is open
const isSidebarVisible = isSidebarOpen && !txFlow
@@ -32,25 +57,54 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE
return (
<>
{!hideHeader && (
{!hideHeader && isSpaceRoute && (
<div className={css.topbar}>
<Topbar onMenuToggle={menuToggleHandler} />
</div>
)}
{!hideHeader && !isSpaceRoute && (
<header className={css.header}>
<Header onMenuToggle={isSidebarRoute ? setSidebarOpen : undefined} onBatchToggle={setBatchOpen} />
<Header onMenuToggle={menuToggleHandler} onBatchToggle={setBatchOpen} />
</header>
)}
{isSidebarRoute ? <SideDrawer isOpen={isSidebarVisible} onToggle={setSidebarOpen} /> : null}
{isSidebarRoute ? (
<SideDrawer
isOpen={isSidebarVisible}
onToggle={setSidebarOpen}
onSidebarOpenChange={isSpaceRoute ? setSpacesSidebarExpanded : undefined}
/>
) : null}
<div
className={classnames(css.main, {
[css.mainNoSidebar]: !isSidebarVisible || !isSidebarRoute,
[css.mainAnimated]: isSidebarRoute && isAnimated,
[css.mainNoHeader]: hideHeader,
[css.mainSpace]: isSpaceRoute,
[css.mainSpaceCollapsed]: isSpaceRoute && !isSpacesSidebarExpanded,
})}
>
<div className={css.content}>
<SafeLoadingError>
{!hideHeader && <Breadcrumbs />}
{children}
{!hideHeader && !isSpaceRoute && pathname === AppRoutes.home && <SpaceSafeBar />}
{isOnboardingRoute ? (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className={css.onboardingMotion}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: 'easeInOut' }}
>
{children}
</motion.div>
</AnimatePresence>
) : (
children
)}
</SafeLoadingError>
</div>
@@ -1,3 +1,22 @@
@reference '../../../styles/shadcn.css';
.topbar {
@apply absolute top-0 right-0 z-[1];
}
@media (max-width: 767px) {
.topbar {
@apply left-0;
}
}
.mainSpace {
/* we need it in order to have higher importance than the .main class */
&& {
@apply pt-14;
}
}
.header {
position: fixed;
left: 0;
@@ -25,6 +44,16 @@
padding-left: 0;
}
/* Spaces: use black background in dark mode (matches shadcn --background) */
[data-theme='dark'] .mainSpace {
background-color: var(--background);
}
/* Spaces: reduce left padding when the sidebar is collapsed to icon mode */
.mainSpaceCollapsed {
padding-left: 4rem;
}
.mainNoHeader {
padding-top: 0;
}
@@ -35,6 +64,17 @@
display: flex;
flex-direction: column;
flex-wrap: wrap;
min-width: 0;
}
/* Onboarding flows: prevent flex descendants from forcing horizontal overflow on narrow viewports */
.onboardingMotion {
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.content main {
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react'
import SpaceBackLink from './SpaceBackLink'
import { getDeterministicColor } from '@/features/spaces'
jest.mock('@/features/spaces', () => ({
...jest.requireActual('@/features/spaces'),
getDeterministicColor: jest.fn((name: string) => `#${name.length.toString(16).padStart(6, '0')}`),
}))
describe('SpaceBackLink', () => {
const mockSpace = { id: 42, name: 'Acme Corp' }
it('renders the first letter of the space name uppercased', () => {
render(<SpaceBackLink space={mockSpace} onClick={jest.fn()} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<SpaceBackLink space={mockSpace} onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('uses getDeterministicColor with space.name for the avatar background', () => {
render(<SpaceBackLink space={mockSpace} onClick={jest.fn()} />)
expect(getDeterministicColor).toHaveBeenCalledWith('Acme Corp')
})
it('handles single-character space name', () => {
render(<SpaceBackLink space={{ id: 1, name: 'X' }} onClick={jest.fn()} />)
expect(screen.getByText('X')).toBeInTheDocument()
})
it('handles lowercase space name by uppercasing the initial', () => {
render(<SpaceBackLink space={{ id: 1, name: 'hello' }} onClick={jest.fn()} />)
expect(screen.getByText('H')).toBeInTheDocument()
})
})
@@ -0,0 +1,25 @@
import BackLink from '@/components/common/BackLink'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { getDeterministicColor } from '@/features/spaces'
type SpaceBackLinkProps = {
space: { id: number; name: string }
onClick: () => void
}
function SpaceBackLink({ space, onClick }: SpaceBackLinkProps) {
return (
<BackLink onClick={onClick} ariaLabel="Back to space">
<Avatar className="size-8 shrink-0">
<AvatarFallback
className="rounded-md text-primary-foreground text-sm font-semibold"
style={{ backgroundColor: getDeterministicColor(space.name) }}
>
{space.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</BackLink>
)
}
export default SpaceBackLink
@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/react'
import { createMockStory } from '@/stories/mocks'
import ChainSelectorBlock from '@/features/spaces/components/SafeSelectorDropdown/components/ChainSelectorBlock'
const deployedChains = {
single: [{ chainId: '1', chainName: 'Ethereum', chainLogoUri: undefined, shortName: 'eth' }],
multi: [
{ chainId: '1', chainName: 'Ethereum', chainLogoUri: undefined, shortName: 'eth' },
{ chainId: '137', chainName: 'Polygon', chainLogoUri: undefined, shortName: 'matic' },
{ chainId: '8453', chainName: 'Base', chainLogoUri: undefined, shortName: 'base' },
],
}
const availableChains = [
{ chainId: '42161', chainName: 'Arbitrum', chainLogoUri: undefined, shortName: 'arb1' },
{ chainId: '100', chainName: 'Gnosis Chain', chainLogoUri: undefined, shortName: 'gno' },
{ chainId: '11155111', chainName: 'Sepolia', chainLogoUri: undefined, shortName: 'sep' },
]
const defaultSetup = createMockStory({
scenario: 'efSafe',
wallet: 'disconnected',
layout: 'paper',
shadcn: true,
})
/**
* Visual stories for the SpaceChainSelector container + ChainSelectorBlock.
* SpaceChainSelector itself is a thin hook wrapper — these stories document
* the visual states directly via ChainSelectorBlock.
*/
const meta = {
title: 'Features/Spaces/SpaceChainSelector',
parameters: {
layout: 'centered',
...defaultSetup.parameters,
},
decorators: [defaultSetup.decorator],
tags: ['autodocs'],
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="inline-flex items-center shadow-[0px_4px_20px_0px_rgba(0,0,0,0.07)] rounded-lg bg-card min-w-[64px] min-h-[68px]">
{children}
</div>
)
export const SingleChain: Story = {
render: () => (
<Wrapper>
<ChainSelectorBlock
deployedChains={deployedChains.single}
availableChains={availableChains}
selectedChainId="1"
onChainSelect={() => {}}
onAddNetwork={() => {}}
/>
</Wrapper>
),
}
export const MultiChain: Story = {
render: () => (
<Wrapper>
<ChainSelectorBlock
deployedChains={deployedChains.multi}
availableChains={availableChains}
selectedChainId="1"
onChainSelect={() => {}}
onAddNetwork={() => {}}
/>
</Wrapper>
),
}
export const NoAvailableChains: Story = {
render: () => (
<Wrapper>
<ChainSelectorBlock
deployedChains={deployedChains.multi}
availableChains={[]}
selectedChainId="1"
onChainSelect={() => {}}
onAddNetwork={() => {}}
/>
</Wrapper>
),
}
@@ -0,0 +1,109 @@
import { render, screen } from '@testing-library/react'
import SpaceChainSelector from './SpaceChainSelector'
import { useSpaceChainSelector } from './hooks/useSpaceChainSelector'
jest.mock('./hooks/useSpaceChainSelector')
jest.mock(
'@/features/spaces/components/SafeSelectorDropdown/components/ChainSelectorBlock',
() =>
function ChainSelectorBlock({
deployedChains,
selectedChainId,
}: {
deployedChains: Array<{ chainId: string }>
selectedChainId: string
}) {
return (
<div
data-testid="chain-selector-block"
data-deployed-count={String(deployedChains.length)}
data-selected-chain-id={selectedChainId}
/>
)
},
)
jest.mock('@/features/multichain', () => ({
CreateSafeOnNewChain: () => <div data-testid="create-safe-on-new-chain" />,
}))
const mockUseSpaceChainSelector = useSpaceChainSelector as jest.Mock
const singleChain = [{ chainId: '1', chainName: 'Ethereum', chainLogoUri: null, shortName: 'eth' }]
const multiChain = [
{ chainId: '1', chainName: 'Ethereum', chainLogoUri: null, shortName: 'eth' },
{ chainId: '137', chainName: 'Polygon', chainLogoUri: null, shortName: 'matic' },
]
const availableChains = [{ chainId: '42161', chainName: 'Arbitrum', chainLogoUri: null, shortName: 'arb1' }]
describe('SpaceChainSelector', () => {
beforeEach(() => {
mockUseSpaceChainSelector.mockReturnValue({
deployedChains: singleChain,
availableChains,
selectedChainId: '1',
deployedChainIds: ['1'],
safeAddress: '0xSafe1',
safeName: 'My Safe',
handleChainChange: jest.fn(),
})
})
it('renders null when deployedChains is empty', () => {
mockUseSpaceChainSelector.mockReturnValue({
deployedChains: [],
availableChains: [],
selectedChainId: '1',
deployedChainIds: [],
safeAddress: '0xSafe1',
safeName: undefined,
handleChainChange: jest.fn(),
})
const { container } = render(<SpaceChainSelector />)
expect(container).toBeEmptyDOMElement()
})
it('renders ChainSelectorBlock when deployed chains are present', () => {
render(<SpaceChainSelector />)
expect(screen.getByTestId('chain-selector-block')).toBeInTheDocument()
})
it('passes deployed chain count to ChainSelectorBlock', () => {
render(<SpaceChainSelector />)
expect(screen.getByTestId('chain-selector-block')).toHaveAttribute('data-deployed-count', '1')
})
it('passes correct deployed count for multi-chain safe', () => {
mockUseSpaceChainSelector.mockReturnValue({
deployedChains: multiChain,
availableChains,
selectedChainId: '1',
deployedChainIds: ['1', '137'],
safeAddress: '0xSafe2',
safeName: 'Multi Safe',
handleChainChange: jest.fn(),
})
render(<SpaceChainSelector />)
expect(screen.getByTestId('chain-selector-block')).toHaveAttribute('data-deployed-count', '2')
})
it('passes selectedChainId to ChainSelectorBlock', () => {
mockUseSpaceChainSelector.mockReturnValue({
deployedChains: multiChain,
availableChains,
selectedChainId: '137',
deployedChainIds: ['1', '137'],
safeAddress: '0xSafe2',
safeName: 'Multi Safe',
handleChainChange: jest.fn(),
})
render(<SpaceChainSelector />)
expect(screen.getByTestId('chain-selector-block')).toHaveAttribute('data-selected-chain-id', '137')
})
})
@@ -0,0 +1,53 @@
import { useState, useCallback } from 'react'
import ChainSelectorBlock from '@/features/spaces/components/SafeSelectorDropdown/components/ChainSelectorBlock'
import { CreateSafeOnNewChain } from '@/features/multichain'
import { useSpaceChainSelector } from './hooks/useSpaceChainSelector'
function SpaceChainSelector() {
const {
deployedChains,
availableChains,
selectedChainId,
deployedChainIds,
safeAddress,
safeName,
handleChainChange,
} = useSpaceChainSelector()
const [addNetworkChainId, setAddNetworkChainId] = useState<string>()
const handleAddNetwork = useCallback((chainId: string) => {
setAddNetworkChainId(chainId)
}, [])
const handleCloseDialog = useCallback(() => {
setAddNetworkChainId(undefined)
}, [])
if (!deployedChains.length) return null
return (
<div className="self-stretch sm:order-last flex items-stretch shadow-[0px_4px_20px_0px_rgba(0,0,0,0.03)] rounded-lg bg-card">
<ChainSelectorBlock
deployedChains={deployedChains}
availableChains={availableChains}
selectedChainId={selectedChainId}
onChainSelect={handleChainChange}
onAddNetwork={handleAddNetwork}
/>
{addNetworkChainId && (
<CreateSafeOnNewChain
open
onClose={handleCloseDialog}
currentName={safeName}
safeAddress={safeAddress}
deployedChainIds={deployedChainIds}
defaultChainId={addNetworkChainId}
/>
)}
</div>
)
}
export default SpaceChainSelector
@@ -0,0 +1,110 @@
import { renderHook, act } from '@testing-library/react'
import { useSpaceBackLink } from './useSpaceBackLink'
const mockPush = jest.fn()
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}))
jest.mock('@/features/spaces', () => ({
useCurrentSpaceId: jest.fn(),
}))
jest.mock('@safe-global/store/gateway/AUTO_GENERATED/spaces', () => ({
useSpacesGetOneV1Query: jest.fn(),
}))
jest.mock('@/store', () => ({
useAppSelector: jest.fn(),
}))
import { useRouter } from 'next/router'
import { useCurrentSpaceId } from '@/features/spaces'
import { useSpacesGetOneV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces'
import { useAppSelector } from '@/store'
import { AppRoutes } from '@/config/routes'
const mockUseRouter = useRouter as jest.Mock
const mockUseCurrentSpaceId = useCurrentSpaceId as jest.Mock
const mockUseSpacesGetOneV1Query = useSpacesGetOneV1Query as jest.Mock
const mockUseAppSelector = useAppSelector as jest.Mock
function setupDefaults(overrides: { spaceId?: string | null; isSignedIn?: boolean; space?: object | null } = {}) {
mockUseRouter.mockReturnValue({ push: mockPush })
mockUseCurrentSpaceId.mockReturnValue('spaceId' in overrides ? overrides.spaceId : '42')
mockUseAppSelector.mockReturnValue(overrides.isSignedIn ?? true)
mockUseSpacesGetOneV1Query.mockReturnValue({
currentData: 'space' in overrides ? overrides.space : { id: 42, name: 'Acme Corp' },
})
}
describe('useSpaceBackLink', () => {
beforeEach(() => {
jest.resetAllMocks()
setupDefaults()
})
it('returns space data from the query', () => {
const { result } = renderHook(() => useSpaceBackLink())
expect(result.current.space).toEqual({ id: 42, name: 'Acme Corp' })
})
it('navigates to the space page when handleBackToSpace is called', () => {
const { result } = renderHook(() => useSpaceBackLink())
act(() => {
result.current.handleBackToSpace()
})
expect(mockPush).toHaveBeenCalledWith({
pathname: AppRoutes.spaces.index,
query: { spaceId: '42' },
})
})
it('does not navigate when spaceId is undefined', () => {
setupDefaults({ spaceId: undefined })
const { result } = renderHook(() => useSpaceBackLink())
act(() => {
result.current.handleBackToSpace()
})
expect(mockPush).not.toHaveBeenCalled()
})
it('skips the query when user is not signed in', () => {
setupDefaults({ isSignedIn: false })
renderHook(() => useSpaceBackLink())
expect(mockUseSpacesGetOneV1Query).toHaveBeenCalledWith({ id: 42 }, expect.objectContaining({ skip: true }))
})
it('skips the query when spaceId is not available', () => {
setupDefaults({ spaceId: undefined })
renderHook(() => useSpaceBackLink())
expect(mockUseSpacesGetOneV1Query).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ skip: true }))
})
it('does not skip the query when both signed in and spaceId are available', () => {
setupDefaults({ spaceId: '10', isSignedIn: true })
renderHook(() => useSpaceBackLink())
expect(mockUseSpacesGetOneV1Query).toHaveBeenCalledWith({ id: 10 }, expect.objectContaining({ skip: false }))
})
it('returns undefined space when query has no data', () => {
setupDefaults({ space: undefined })
const { result } = renderHook(() => useSpaceBackLink())
expect(result.current.space).toBeUndefined()
})
})
@@ -0,0 +1,25 @@
import { useCallback } from 'react'
import { useRouter } from 'next/router'
import { useCurrentSpaceId } from '@/features/spaces'
import { useSpacesGetOneV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/spaces'
import { useAppSelector } from '@/store'
import { isAuthenticated } from '@/store/authSlice'
import { AppRoutes } from '@/config/routes'
export function useSpaceBackLink() {
const spaceId = useCurrentSpaceId()
const isUserSignedIn = useAppSelector(isAuthenticated)
const { currentData: space } = useSpacesGetOneV1Query({ id: Number(spaceId) }, { skip: !isUserSignedIn || !spaceId })
const router = useRouter()
const handleBackToSpace = useCallback(() => {
if (spaceId) {
router.push({
pathname: AppRoutes.spaces.index,
query: { spaceId },
})
}
}, [spaceId, router])
return { space, handleBackToSpace }
}
@@ -0,0 +1,262 @@
import { renderHook, act } from '@testing-library/react'
import { useSpaceChainSelector } from './useSpaceChainSelector'
import type { SafeItem } from '@/hooks/safes/useAllSafes'
import type { MultiChainSafeItem } from '@/hooks/safes/useAllSafesGrouped'
// ── mocks ──────────────────────────────────────────────────────────────
jest.mock('@/features/spaces', () => ({
useSpaceSafes: jest.fn(),
useCurrentSpaceId: jest.fn(() => '42'),
}))
jest.mock('@/services/analytics', () => ({
trackEvent: jest.fn(),
}))
jest.mock('@/services/analytics/events/spaces', () => ({
SPACE_EVENTS: {
CHAIN_SWITCHED: { action: 'Chain switched', category: 'spaces' },
},
}))
jest.mock('@/services/analytics/mixpanel-events', () => ({
MixpanelEventParams: {
SAFE_ADDRESS: 'Safe Address',
CHAIN_ID: 'Chain ID',
},
}))
jest.mock('@/hooks/safes', () => ({
isMultiChainSafeItem: jest.fn(),
}))
jest.mock('@/hooks/useSafeInfo', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/hooks/useChainId', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/hooks/useChains', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}))
jest.mock('@/config/routes', () => ({
AppRoutes: { home: '/home' },
}))
import { useSpaceSafes, useCurrentSpaceId } from '@/features/spaces'
import { trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
import { MixpanelEventParams } from '@/services/analytics/mixpanel-events'
import useSafeInfo from '@/hooks/useSafeInfo'
import useChainId from '@/hooks/useChainId'
import useChains from '@/hooks/useChains'
import { useRouter } from 'next/router'
import { isMultiChainSafeItem } from '@/hooks/safes'
// ── helpers ────────────────────────────────────────────────────────────
const chainConfigs = [
{ chainId: '1', chainName: 'Ethereum', chainLogoUri: 'eth.png', shortName: 'eth' },
{ chainId: '137', chainName: 'Polygon', chainLogoUri: 'polygon.png', shortName: 'matic' },
{ chainId: '42161', chainName: 'Arbitrum', chainLogoUri: 'arb.png', shortName: 'arb1' },
]
const mockPush = jest.fn()
const singleChainSafe: SafeItem = {
chainId: '1',
address: '0xSafe1',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: 'My Safe',
}
const multiChainSafe: MultiChainSafeItem = {
address: '0xSafe2',
isPinned: false,
lastVisited: 0,
name: 'Multi Safe',
safes: [
{ chainId: '1', address: '0xSafe2', isReadOnly: false, isPinned: false, lastVisited: 0, name: 'Multi Safe' },
{ chainId: '137', address: '0xSafe2', isReadOnly: false, isPinned: false, lastVisited: 0, name: 'Multi Safe' },
],
}
function setupDefaults(
overrides: {
allSafes?: Array<SafeItem | MultiChainSafeItem>
safeAddress?: string
currentChainId?: string
} = {},
) {
const allSafes = overrides.allSafes ?? [singleChainSafe]
;(useSpaceSafes as jest.Mock).mockReturnValue({ allSafes })
;(useCurrentSpaceId as jest.Mock).mockReturnValue('42')
;(useSafeInfo as jest.Mock).mockReturnValue({ safeAddress: overrides.safeAddress ?? '0xSafe1' })
;(useChainId as jest.Mock).mockReturnValue(overrides.currentChainId ?? '1')
;(useChains as jest.Mock).mockReturnValue({ configs: chainConfigs })
;(useRouter as jest.Mock).mockReturnValue({ push: mockPush })
;(isMultiChainSafeItem as unknown as jest.Mock).mockImplementation(
(item: SafeItem | MultiChainSafeItem) => 'safes' in item,
)
}
// ── tests ──────────────────────────────────────────────────────────────
describe('useSpaceChainSelector', () => {
beforeEach(() => {
jest.resetAllMocks()
setupDefaults()
})
it('returns the deployed chains for the current safe', () => {
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChains).toHaveLength(1)
expect(result.current.deployedChains[0]).toMatchObject({ chainId: '1', chainName: 'Ethereum', shortName: 'eth' })
})
it('returns selectedChainId from useChainId', () => {
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.selectedChainId).toBe('1')
})
it('returns deployedChainIds for a single-chain safe', () => {
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChainIds).toEqual(['1'])
})
it('returns deployedChainIds for a multi-chain safe', () => {
setupDefaults({ allSafes: [multiChainSafe], safeAddress: '0xSafe2' })
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChainIds).toEqual(['1', '137'])
expect(result.current.deployedChains).toHaveLength(2)
})
it('returns availableChains excluding deployed chains', () => {
setupDefaults({ allSafes: [singleChainSafe], safeAddress: '0xSafe1' })
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.availableChains).toHaveLength(2)
expect(result.current.availableChains.map((c) => c.chainId)).toEqual(['137', '42161'])
})
it('returns availableChains excluding all deployed chains for multi-chain safe', () => {
setupDefaults({ allSafes: [multiChainSafe], safeAddress: '0xSafe2' })
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.availableChains).toHaveLength(1)
expect(result.current.availableChains[0].chainId).toBe('42161')
})
it('returns empty deployedChains when the current safe is not found in allSafes', () => {
setupDefaults({ allSafes: [], safeAddress: '0xSafe1' })
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChains).toHaveLength(0)
expect(result.current.deployedChainIds).toEqual([])
})
it('navigates to the same safe on a different chain when handleChainChange is called', () => {
setupDefaults({ allSafes: [multiChainSafe], safeAddress: '0xSafe2' })
const { result } = renderHook(() => useSpaceChainSelector())
act(() => {
result.current.handleChainChange('137')
})
expect(mockPush).toHaveBeenCalledWith({
pathname: '/home',
query: { safe: 'matic:0xSafe2' },
})
})
it('does not navigate when chain config is not found', () => {
const { result } = renderHook(() => useSpaceChainSelector())
act(() => {
result.current.handleChainChange('999')
})
expect(mockPush).not.toHaveBeenCalled()
})
it('matches current safe using case-insensitive address comparison', () => {
setupDefaults({ allSafes: [singleChainSafe], safeAddress: '0xsafe1' })
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChains).toHaveLength(1)
})
it('falls back to null for chainLogoUri when chain config exists but chainLogoUri is missing', () => {
;(useChains as jest.Mock).mockReturnValue({
configs: [{ chainId: '1', chainName: 'Ethereum', shortName: 'eth' }],
})
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChains[0]).toMatchObject({
chainId: '1',
chainName: 'Ethereum',
shortName: 'eth',
chainLogoUri: null,
})
})
it('falls back to chainId for chainName/shortName when chain config is missing', () => {
const unknownSafe: SafeItem = {
chainId: '99999',
address: '0xUnknown',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: 'Unknown Safe',
}
setupDefaults({ allSafes: [unknownSafe], safeAddress: '0xUnknown', currentChainId: '99999' })
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.deployedChains[0]).toMatchObject({
chainId: '99999',
chainName: '99999',
shortName: '99999',
chainLogoUri: null,
})
})
it('returns safeAddress from useSafeInfo', () => {
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.safeAddress).toBe('0xSafe1')
})
it('returns safeName from the matched safe item', () => {
const { result } = renderHook(() => useSpaceChainSelector())
expect(result.current.safeName).toBe('My Safe')
})
it('fires CHAIN_SWITCHED trackEvent exactly once with correct params on chain change', () => {
setupDefaults({ allSafes: [multiChainSafe], safeAddress: '0xSafe2' })
const { result } = renderHook(() => useSpaceChainSelector())
act(() => {
result.current.handleChainChange('137')
})
expect(trackEvent).toHaveBeenCalledTimes(1)
expect(trackEvent).toHaveBeenCalledWith(
{ ...SPACE_EVENTS.CHAIN_SWITCHED, label: '42' },
{
spaceId: '42',
[MixpanelEventParams.SAFE_ADDRESS]: '0xSafe2',
[MixpanelEventParams.CHAIN_ID]: '137',
},
)
})
})
@@ -0,0 +1,82 @@
import { useCallback, useMemo } from 'react'
import { useRouter } from 'next/router'
import { useSpaceSafes } from '@/features/spaces'
import { isMultiChainSafeItem } from '@/hooks/safes'
import useSafeInfo from '@/hooks/useSafeInfo'
import useChainId from '@/hooks/useChainId'
import useChains from '@/hooks/useChains'
import { AppRoutes } from '@/config/routes'
import { trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
import { MixpanelEventParams } from '@/services/analytics/mixpanel-events'
import { useCurrentSpaceId } from '@/features/spaces'
import { sameAddress } from '@safe-global/utils/utils/addresses'
import type { ChainInfo } from '@/features/spaces/types'
export function useSpaceChainSelector() {
const { allSafes } = useSpaceSafes()
const { safeAddress } = useSafeInfo()
const selectedChainId = useChainId()
const { configs: chainConfigs } = useChains()
const router = useRouter()
const spaceId = useCurrentSpaceId()
const { deployedChains, deployedChainIds, safeName } = useMemo(() => {
const currentSafe = allSafes.find((s) => sameAddress(s.address, safeAddress))
if (!currentSafe)
return { deployedChains: [] as ChainInfo[], deployedChainIds: [] as string[], safeName: undefined }
const chainIds = isMultiChainSafeItem(currentSafe) ? currentSafe.safes.map((s) => s.chainId) : [currentSafe.chainId]
const resolvedChains: ChainInfo[] = chainIds.map((id) => {
const chain = chainConfigs.find((c) => c.chainId === id)
return {
chainId: id,
chainName: chain?.chainName ?? id,
chainLogoUri: chain?.chainLogoUri ?? null,
shortName: chain?.shortName ?? id,
}
})
return { deployedChains: resolvedChains, deployedChainIds: chainIds, safeName: currentSafe.name }
}, [allSafes, safeAddress, chainConfigs])
const availableChains: ChainInfo[] = useMemo(() => {
return chainConfigs
.filter((c) => !deployedChainIds.includes(c.chainId))
.map((c) => ({
chainId: c.chainId,
chainName: c.chainName,
chainLogoUri: c.chainLogoUri ?? null,
shortName: c.shortName,
}))
}, [chainConfigs, deployedChainIds])
const handleChainChange = useCallback(
(chainId: string) => {
const chain = chainConfigs.find((c) => c.chainId === chainId)
if (!chain) return
trackEvent(
{ ...SPACE_EVENTS.CHAIN_SWITCHED, label: spaceId ?? undefined },
{
spaceId,
[MixpanelEventParams.SAFE_ADDRESS]: safeAddress,
[MixpanelEventParams.CHAIN_ID]: chainId,
},
)
router.push({ pathname: AppRoutes.home, query: { safe: `${chain.shortName}:${safeAddress}` } })
},
[chainConfigs, router, safeAddress, spaceId],
)
return {
deployedChains,
availableChains,
selectedChainId,
deployedChainIds,
safeAddress,
safeName,
handleChainChange,
}
}
@@ -0,0 +1,562 @@
import { renderHook, act } from '@testing-library/react'
import { useSpaceSafeSelectorItems } from './useSpaceSafeSelectorItems'
import type { SafeItem } from '@/hooks/safes/useAllSafes'
import type { MultiChainSafeItem } from '@/hooks/safes/useAllSafesGrouped'
// ── mocks ──────────────────────────────────────────────────────────────
jest.mock('@/features/spaces', () => ({
useSpaceSafes: jest.fn(),
useCurrentSpaceId: jest.fn(),
}))
jest.mock('@/services/analytics', () => ({
...jest.requireActual('@/services/analytics'),
trackEvent: jest.fn(),
}))
jest.mock('@/hooks/useSafeInfo', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/hooks/useChainId', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/hooks/useChains', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/store/api/gateway', () => ({
useGetMultipleSafeOverviewsQuery: jest.fn(),
}))
jest.mock('@/store', () => ({
useAppSelector: jest.fn(),
}))
jest.mock('@/store/settingsSlice', () => ({
selectCurrency: jest.fn(),
}))
jest.mock('@/hooks/wallets/useWallet', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}))
jest.mock('@/config/routes', () => ({
AppRoutes: {
home: '/home',
spaces: {
index: '/spaces',
settings: '/spaces/settings',
members: '/spaces/members',
safeAccounts: '/spaces/safe-accounts',
addressBook: '/spaces/address-book',
},
welcome: { spaces: '/welcome/spaces' },
},
}))
import { useSpaceSafes, useCurrentSpaceId } from '@/features/spaces'
import { trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
import { MixpanelEventParams } from '@/services/analytics/mixpanel-events'
import useSafeInfo from '@/hooks/useSafeInfo'
import useChainId from '@/hooks/useChainId'
import useChains from '@/hooks/useChains'
import { useGetMultipleSafeOverviewsQuery } from '@/store/api/gateway'
import { useAppSelector } from '@/store'
import useWallet from '@/hooks/wallets/useWallet'
import { useRouter } from 'next/router'
// ── helpers ────────────────────────────────────────────────────────────
const singleChainSafe: SafeItem = {
chainId: '1',
address: '0xSafe1',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: 'My Safe',
}
const multiChainSafe: MultiChainSafeItem = {
address: '0xSafe2',
isPinned: false,
lastVisited: 0,
name: 'Multi Safe',
safes: [
{ chainId: '1', address: '0xSafe2', isReadOnly: false, isPinned: false, lastVisited: 0, name: 'Multi Safe' },
{ chainId: '137', address: '0xSafe2', isReadOnly: false, isPinned: false, lastVisited: 0, name: 'Multi Safe' },
],
}
const unnamedSafe: SafeItem = {
chainId: '1',
address: '0xUnnamed',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: undefined,
}
const chainConfigs = [
{ chainId: '1', chainName: 'Ethereum', chainLogoUri: 'eth.png', shortName: 'eth' },
{ chainId: '137', chainName: 'Polygon', chainLogoUri: 'polygon.png', shortName: 'matic' },
]
const mockPush = jest.fn()
function setupDefaults(
overrides: {
allSafes?: Array<SafeItem | MultiChainSafeItem>
safeAddress?: string
currentChainId?: string
spaceId?: string | null
overviews?: Array<{
address: { value: string }
chainId: string
fiatTotal: string
threshold: number
owners: { value: string }[]
}>
overviewsLoading?: boolean
overviewsError?: boolean
} = {},
) {
;(useSpaceSafes as jest.Mock).mockReturnValue({
allSafes: overrides.allSafes ?? [singleChainSafe],
})
;(useCurrentSpaceId as jest.Mock).mockReturnValue(overrides.spaceId ?? '42')
;(useSafeInfo as jest.Mock).mockReturnValue({
safe: { threshold: 2, owners: [{ value: '0xOwner1' }, { value: '0xOwner2' }] },
safeAddress: overrides.safeAddress ?? '0xSafe1',
})
;(useChainId as jest.Mock).mockReturnValue(overrides.currentChainId ?? '1')
;(useChains as jest.Mock).mockReturnValue({ configs: chainConfigs })
;(useGetMultipleSafeOverviewsQuery as jest.Mock).mockReturnValue({
data: overrides.overviews ?? [
{ address: { value: '0xSafe1' }, chainId: '1', fiatTotal: '5000', threshold: 2, owners: [{ value: '0xOwner1' }] },
],
isLoading: overrides.overviewsLoading ?? false,
isError: overrides.overviewsError ?? false,
refetch: jest.fn(),
})
;(useAppSelector as jest.Mock).mockReturnValue('usd')
;(useWallet as jest.Mock).mockReturnValue({ address: '0xWallet' })
;(useRouter as jest.Mock).mockReturnValue({ push: mockPush })
}
// ── tests ──────────────────────────────────────────────────────────────
describe('useSpaceSafeSelectorItems', () => {
beforeEach(() => {
jest.resetAllMocks()
setupDefaults()
})
// ── items populated from space safes ──
it('returns items populated from user space safes', () => {
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items).toHaveLength(1)
expect(result.current.items[0]).toMatchObject({
id: '1:0xSafe1',
name: 'My Safe',
address: '0xSafe1',
})
})
it('sets the selectedItemId to currentChainId:safeAddress', () => {
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.selectedItemId).toBe('1:0xSafe1')
})
// ── unnamed safe shows empty name (address display handled by UI) ──
it('uses empty string for name when safe has no name', () => {
setupDefaults({
allSafes: [unnamedSafe],
safeAddress: '0xUnnamed',
overviews: [
{ address: { value: '0xUnnamed' }, chainId: '1', fiatTotal: '100', threshold: 1, owners: [{ value: '0xO' }] },
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].name).toBe('')
expect(result.current.items[0].address).toBe('0xUnnamed')
})
// ── multi-chain safe ──
it('returns multi-chain safe with all chains and aggregated balance', () => {
setupDefaults({
allSafes: [multiChainSafe],
safeAddress: '0xSafe2',
currentChainId: '1',
overviews: [
{
address: { value: '0xSafe2' },
chainId: '1',
fiatTotal: '3000',
threshold: 2,
owners: [{ value: '0xO1' }, { value: '0xO2' }],
},
{
address: { value: '0xSafe2' },
chainId: '137',
fiatTotal: '2000',
threshold: 2,
owners: [{ value: '0xO1' }, { value: '0xO2' }],
},
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
const item = result.current.items[0]
expect(item.chains).toHaveLength(2)
expect(item.chains[0].chainId).toBe('1') // current chain first
expect(item.chains[1].chainId).toBe('137')
// balance = sum of all chain balances
expect(item.balance).toBe('5000')
})
it('places the current chainId first in chains list for selected multi-chain safe', () => {
setupDefaults({
allSafes: [multiChainSafe],
safeAddress: '0xSafe2',
currentChainId: '137',
overviews: [
{ address: { value: '0xSafe2' }, chainId: '1', fiatTotal: '100', threshold: 1, owners: [{ value: '0xO' }] },
{ address: { value: '0xSafe2' }, chainId: '137', fiatTotal: '200', threshold: 1, owners: [{ value: '0xO' }] },
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].chains[0].chainId).toBe('137')
})
// ── chain info with prefixes ──
it('includes chain shortName (prefix) in chain info', () => {
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].chains[0]).toMatchObject({
chainId: '1',
chainName: 'Ethereum',
shortName: 'eth',
})
})
// ── balance and threshold from overview ──
it('uses overview data for balance and threshold of non-current safes', () => {
const otherSafe: SafeItem = {
chainId: '1',
address: '0xOther',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: 'Other',
}
setupDefaults({
allSafes: [singleChainSafe, otherSafe],
safeAddress: '0xSafe1',
overviews: [
{
address: { value: '0xSafe1' },
chainId: '1',
fiatTotal: '5000',
threshold: 2,
owners: [{ value: '0xO1' }, { value: '0xO2' }],
},
{
address: { value: '0xOther' },
chainId: '1',
fiatTotal: '8000',
threshold: 3,
owners: [{ value: '0xO1' }, { value: '0xO2' }, { value: '0xO3' }],
},
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
const otherItem = result.current.items.find((i) => i.address === '0xOther')
expect(otherItem?.balance).toBe('8000')
expect(otherItem?.threshold).toBe(3)
expect(otherItem?.owners).toBe(3)
})
// ── loading state ──
it('marks items as loading when overviews are still loading', () => {
setupDefaults({ overviewsLoading: true, overviews: [] as never[] })
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].isLoading).toBe(true)
})
// ── error state ──
it('returns isError=true when overview query fails', () => {
setupDefaults({ overviewsError: true })
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.isError).toBe(true)
})
// ── empty safes ──
it('returns empty items when there are no safes in the space', () => {
setupDefaults({ allSafes: [] })
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items).toEqual([])
})
// ── selecting a safe triggers navigation ──
it('navigates to the selected safe with chain prefix on item select', () => {
const { result } = renderHook(() => useSpaceSafeSelectorItems())
act(() => {
result.current.handleItemSelect('1:0xNewSafe')
})
expect(mockPush).toHaveBeenCalledWith({
pathname: '/home',
query: { safe: 'eth:0xNewSafe' },
})
})
it('does not navigate when chain config is not found', () => {
const { result } = renderHook(() => useSpaceSafeSelectorItems())
act(() => {
result.current.handleItemSelect('999:0xSafe1')
})
expect(mockPush).not.toHaveBeenCalled()
})
// ── skipToken when no safes ──
it('passes skipToken to overview query when there are no flat safes', () => {
setupDefaults({ allSafes: [] })
renderHook(() => useSpaceSafeSelectorItems())
// When flatSafes is empty, useGetMultipleSafeOverviewsQuery is called with skipToken (Symbol)
const queryArg = (useGetMultipleSafeOverviewsQuery as jest.Mock).mock.calls[0][0]
expect(typeof queryArg).toBe('symbol')
})
// ── refetch is returned ──
it('returns a refetch function from the hook', () => {
const mockRefetch = jest.fn()
;(useGetMultipleSafeOverviewsQuery as jest.Mock).mockReturnValue({
data: [],
isLoading: false,
isError: false,
refetch: mockRefetch,
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.refetch).toBe(mockRefetch)
})
// ── multi-chain safe NOT current: original chain order preserved ──
it('does not reorder chains for a multi-chain safe that is not the current safe', () => {
setupDefaults({
allSafes: [multiChainSafe],
safeAddress: '0xDifferentSafe', // not the multi-chain safe
currentChainId: '137',
overviews: [
{ address: { value: '0xSafe2' }, chainId: '1', fiatTotal: '100', threshold: 1, owners: [{ value: '0xO' }] },
{ address: { value: '0xSafe2' }, chainId: '137', fiatTotal: '200', threshold: 1, owners: [{ value: '0xO' }] },
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
// Original order from multiChainSafe.safes: ['1', '137']
expect(result.current.items[0].chains[0].chainId).toBe('1')
expect(result.current.items[0].chains[1].chainId).toBe('137')
})
// ── multi-chain safe NOT current: threshold/owners from overview ──
it('uses overview threshold/owners for a multi-chain safe that is not the current safe', () => {
setupDefaults({
allSafes: [multiChainSafe],
safeAddress: '0xDifferentSafe',
overviews: [
{
address: { value: '0xSafe2' },
chainId: '1',
fiatTotal: '100',
threshold: 4,
owners: [{ value: '0x1' }, { value: '0x2' }, { value: '0x3' }, { value: '0x4' }],
},
{
address: { value: '0xSafe2' },
chainId: '137',
fiatTotal: '200',
threshold: 4,
owners: [{ value: '0x1' }, { value: '0x2' }, { value: '0x3' }, { value: '0x4' }],
},
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].threshold).toBe(4)
expect(result.current.items[0].owners).toBe(4)
})
// ── toChainInfo fallback when chain config not found ──
it('falls back to chainId for chainName and shortName when chain config is missing', () => {
const safeOnUnknownChain: SafeItem = {
chainId: '42161',
address: '0xArb',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: 'Arb Safe',
}
setupDefaults({
allSafes: [safeOnUnknownChain],
safeAddress: '0xArb',
currentChainId: '42161',
overviews: [],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].chains[0]).toMatchObject({
chainId: '42161',
chainName: '42161',
shortName: '42161',
chainLogoUri: null,
})
})
// ── single-chain safe balance fallback ──
it('returns balance "0" for a single-chain safe with no overview data', () => {
setupDefaults({ overviews: [] as never[] })
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].balance).toBe('0')
})
// ── isLoading false when overview already loaded ──
it('sets isLoading to false when overview data exists even if query is still loading', () => {
setupDefaults({
overviewsLoading: true,
overviews: [
{
address: { value: '0xSafe1' },
chainId: '1',
fiatTotal: '5000',
threshold: 2,
owners: [{ value: '0xOwner1' }],
},
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].isLoading).toBe(false)
})
// ── current safe uses live safe info ──
it('uses live safe threshold/owners for the currently viewed safe', () => {
;(useSafeInfo as jest.Mock).mockReturnValue({
safe: {
threshold: 5,
owners: [{ value: '0x1' }, { value: '0x2' }, { value: '0x3' }, { value: '0x4' }, { value: '0x5' }],
},
safeAddress: '0xSafe1',
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items[0].threshold).toBe(5)
expect(result.current.items[0].owners).toBe(5)
})
// ── case-insensitive address matching ──
it('matches the current safe using case-insensitive address comparison', () => {
const mixedCaseSafe: SafeItem = {
chainId: '1',
address: '0xAbCdEf',
isReadOnly: false,
isPinned: false,
lastVisited: 0,
name: 'Mixed Case',
}
;(useSafeInfo as jest.Mock).mockReturnValue({
safe: { threshold: 3, owners: [{ value: '0x1' }, { value: '0x2' }, { value: '0x3' }] },
safeAddress: '0xabcdef', // lowercase vs mixed-case in item
})
;(useSpaceSafes as jest.Mock).mockReturnValue({ allSafes: [mixedCaseSafe] })
const { result } = renderHook(() => useSpaceSafeSelectorItems())
// Should use live safe.threshold (3) not overview, proving case-insensitive match worked
expect(result.current.items[0].threshold).toBe(3)
expect(result.current.items[0].owners).toBe(3)
})
// ── tracking:
it('fires SAFE_SELECTED trackEvent exactly once with correct params on item select', () => {
const { result } = renderHook(() => useSpaceSafeSelectorItems())
act(() => {
result.current.handleItemSelect('1:0xNewSafe')
})
expect(trackEvent).toHaveBeenCalledTimes(1)
expect(trackEvent).toHaveBeenCalledWith(
{ ...SPACE_EVENTS.SAFE_SELECTED, label: '42' },
{
spaceId: '42',
[MixpanelEventParams.SAFE_ADDRESS]: '0xNewSafe',
[MixpanelEventParams.CHAIN_ID]: '1',
},
)
})
// ── wallet is null ──
it('does not crash when useWallet returns null', () => {
;(useWallet as jest.Mock).mockReturnValue(null)
const { result } = renderHook(() => useSpaceSafeSelectorItems())
expect(result.current.items).toHaveLength(1)
})
// ── multi-chain safe id uses currentChainId for current safe ──
it('sets multi-chain safe id using currentChainId when it is the current safe', () => {
setupDefaults({
allSafes: [multiChainSafe],
safeAddress: '0xSafe2',
currentChainId: '137',
overviews: [
{ address: { value: '0xSafe2' }, chainId: '1', fiatTotal: '100', threshold: 1, owners: [{ value: '0xO' }] },
{ address: { value: '0xSafe2' }, chainId: '137', fiatTotal: '200', threshold: 1, owners: [{ value: '0xO' }] },
],
})
const { result } = renderHook(() => useSpaceSafeSelectorItems())
// id should start with currentChainId (137), not first chain in safes array (1)
expect(result.current.items[0].id).toBe('137:0xSafe2')
})
})
@@ -0,0 +1,164 @@
import { useMemo, useCallback } from 'react'
import { useRouter } from 'next/router'
import { useSpaceSafes, useCurrentSpaceId } from '@/features/spaces'
import { isMultiChainSafeItem, flattenSafeItems } from '@/hooks/safes'
import type { SafeItem, MultiChainSafeItem } from '@/hooks/safes'
import useSafeInfo from '@/hooks/useSafeInfo'
import useChainId from '@/hooks/useChainId'
import useChains from '@/hooks/useChains'
import { useGetMultipleSafeOverviewsQuery } from '@/store/api/gateway'
import { useAppSelector } from '@/store'
import { selectCurrency } from '@/store/settingsSlice'
import useWallet from '@/hooks/wallets/useWallet'
import { sameAddress } from '@safe-global/utils/utils/addresses'
import { skipToken } from '@reduxjs/toolkit/query'
import { AppRoutes } from '@/config/routes'
import { trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
import { MixpanelEventParams } from '@/services/analytics/mixpanel-events'
import type { SafeItemData } from '@/features/spaces/components/SafeSelectorDropdown/types'
import type { ChainInfo } from '@/features/spaces/types'
import type { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
import type { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
const toChainInfo = (chainId: string, chain: Chain | undefined): ChainInfo => ({
chainId,
chainName: chain?.chainName ?? chainId,
chainLogoUri: chain?.chainLogoUri ?? null,
shortName: chain?.shortName ?? chainId,
})
const sumFiatTotals = (overviews: SafeOverview[]): number =>
overviews.reduce((sum, o) => {
const fiat = parseFloat(o.fiatTotal || '0')
return isNaN(fiat) ? sum : sum + fiat
}, 0)
const resolveThresholdAndOwners = (
isCurrentSafe: boolean,
safe: { threshold: number; owners?: { value: string }[] },
overview: SafeOverview | undefined,
) => ({
threshold: isCurrentSafe ? safe.threshold : (overview?.threshold ?? 0),
owners: isCurrentSafe ? (safe.owners?.length ?? 0) : (overview?.owners.length ?? 0),
})
const mapChainIds = (chainConfigs: Chain[], chainIds: string[]): ChainInfo[] =>
chainIds.map((id) =>
toChainInfo(
id,
chainConfigs.find((c) => c.chainId === id),
),
)
function buildMultiChainItem(
item: MultiChainSafeItem,
isCurrentSafe: boolean,
currentChainId: string,
overviews: SafeOverview[] | undefined,
overviewsLoading: boolean,
safe: { threshold: number; owners?: { value: string }[] },
chainConfigs: Chain[],
): SafeItemData {
const chainIds = item.safes.map((s) => s.chainId)
const orderedChainIds = isCurrentSafe ? [currentChainId, ...chainIds.filter((id) => id !== currentChainId)] : chainIds
const safeOverviews =
overviews?.filter(
(o) => sameAddress(o.address.value, item.address) && item.safes.some((s) => s.chainId === o.chainId),
) ?? []
const firstOverview = safeOverviews[0] as SafeOverview | undefined
return {
id: `${orderedChainIds[0]}:${item.address}`,
name: item.name ?? '',
address: item.address,
...resolveThresholdAndOwners(isCurrentSafe, safe, firstOverview),
balance: sumFiatTotals(safeOverviews).toString(),
isLoading: overviewsLoading && safeOverviews.length === 0,
chains: mapChainIds(chainConfigs, orderedChainIds),
}
}
function buildSingleChainItem(
item: SafeItem,
isCurrentSafe: boolean,
overviews: SafeOverview[] | undefined,
overviewsLoading: boolean,
safe: { threshold: number; owners?: { value: string }[] },
chainConfigs: Chain[],
): SafeItemData {
const overview = overviews?.find((o) => sameAddress(o.address.value, item.address) && o.chainId === item.chainId)
return {
id: `${item.chainId}:${item.address}`,
name: item.name ?? '',
address: item.address,
...resolveThresholdAndOwners(isCurrentSafe, safe, overview),
balance: overview?.fiatTotal ?? '0',
isLoading: overviewsLoading && !overview,
chains: mapChainIds(chainConfigs, [item.chainId]),
}
}
export function useSpaceSafeSelectorItems() {
const { allSafes } = useSpaceSafes()
const { safe, safeAddress } = useSafeInfo()
const currentChainId = useChainId()
const { configs: chainConfigs } = useChains()
const router = useRouter()
const currency = useAppSelector(selectCurrency)
const { address: walletAddress } = useWallet() || {}
const spaceId = useCurrentSpaceId()
const flatSafes = useMemo(() => flattenSafeItems(allSafes), [allSafes])
const {
data: overviews,
isLoading: overviewsLoading,
isError: overviewsError,
refetch: refetchOverviews,
} = useGetMultipleSafeOverviewsQuery(flatSafes.length > 0 ? { safes: flatSafes, currency, walletAddress } : skipToken)
const items: SafeItemData[] = useMemo(() => {
return allSafes.map((item) => {
const isCurrentSafe = item.address.toLowerCase() === safeAddress.toLowerCase()
if (isMultiChainSafeItem(item)) {
return buildMultiChainItem(item, isCurrentSafe, currentChainId, overviews, overviewsLoading, safe, chainConfigs)
}
return buildSingleChainItem(item, isCurrentSafe, overviews, overviewsLoading, safe, chainConfigs)
})
}, [allSafes, safeAddress, currentChainId, safe, overviews, overviewsLoading, chainConfigs])
const selectedItemId = `${currentChainId}:${safeAddress}`
const handleItemSelect = useCallback(
(itemId: string) => {
const colonIndex = itemId.indexOf(':')
const chainId = itemId.slice(0, colonIndex)
const address = itemId.slice(colonIndex + 1)
const chain = chainConfigs.find((c) => c.chainId === chainId)
if (!chain) return
trackEvent(
{ ...SPACE_EVENTS.SAFE_SELECTED, label: spaceId ?? undefined },
{
spaceId,
[MixpanelEventParams.SAFE_ADDRESS]: address,
[MixpanelEventParams.CHAIN_ID]: chainId,
},
)
router.push({ pathname: AppRoutes.home, query: { safe: `${chain.shortName}:${address}` } })
},
[chainConfigs, router, spaceId],
)
return {
items,
selectedItemId,
handleItemSelect,
isError: overviewsError,
refetch: refetchOverviews,
}
}
@@ -0,0 +1,189 @@
import { render } from '@testing-library/react'
import SpaceSafeBar from './index'
const mockItems = [
{
id: '1:0xSafe1',
name: 'My Safe',
address: '0xSafe1',
threshold: 2,
owners: 3,
balance: '1000',
chains: [{ chainId: '1', chainName: 'Ethereum', chainLogoUri: null, shortName: 'eth' }],
},
]
jest.mock('@/features/spaces', () => ({
useIsQualifiedSafe: jest.fn(),
}))
jest.mock('./hooks/useSpaceSafeSelectorItems', () => ({
useSpaceSafeSelectorItems: jest.fn(),
}))
jest.mock('./hooks/useSpaceBackLink', () => ({
useSpaceBackLink: jest.fn(),
}))
jest.mock('./SpaceChainSelector', () => {
const MockSpaceChainSelector = () => <div data-testid="space-chain-selector" />
MockSpaceChainSelector.displayName = 'SpaceChainSelector'
return { __esModule: true, default: MockSpaceChainSelector }
})
jest.mock('@/features/spaces/components/SafeSelectorDropdown', () => {
const MockSafeSelectorDropdown = (props: Record<string, unknown>) => (
<div
data-testid="safe-selector-dropdown"
data-items={JSON.stringify(props.items)}
data-error={String(props.isError)}
data-selected-item-id={props.selectedItemId as string}
data-has-on-item-select={String(typeof props.onItemSelect === 'function')}
data-has-on-retry={String(typeof props.onRetry === 'function')}
/>
)
MockSafeSelectorDropdown.displayName = 'SafeSelectorDropdown'
return { __esModule: true, default: MockSafeSelectorDropdown }
})
jest.mock('./SpaceBackLink', () => {
const MockSpaceBackLink = (props: Record<string, unknown>) => (
<div
data-testid="space-back-link"
data-space-name={(props.space as { name: string })?.name}
data-has-on-click={String(typeof props.onClick === 'function')}
/>
)
MockSpaceBackLink.displayName = 'SpaceBackLink'
return { __esModule: true, default: MockSpaceBackLink }
})
import { useIsQualifiedSafe } from '@/features/spaces'
import { useSpaceSafeSelectorItems } from './hooks/useSpaceSafeSelectorItems'
import { useSpaceBackLink } from './hooks/useSpaceBackLink'
const mockUseIsQualifiedSafe = useIsQualifiedSafe as jest.Mock
const mockUseSpaceSafeSelectorItems = useSpaceSafeSelectorItems as jest.Mock
const mockUseSpaceBackLink = useSpaceBackLink as jest.Mock
describe('SpaceSafeBar', () => {
beforeEach(() => {
jest.resetAllMocks()
mockUseSpaceSafeSelectorItems.mockReturnValue({
items: mockItems,
selectedItemId: '1:0xSafe1',
handleItemSelect: jest.fn(),
isError: false,
refetch: jest.fn(),
})
mockUseSpaceBackLink.mockReturnValue({
space: undefined,
handleBackToSpace: jest.fn(),
})
})
it('renders nothing when the user has no space (useIsQualifiedSafe returns false)', () => {
mockUseIsQualifiedSafe.mockReturnValue(false)
const { container } = render(<SpaceSafeBar />)
expect(container.innerHTML).toBe('')
})
it('renders SafeSelectorDropdown when useIsQualifiedSafe returns true', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
const { getByTestId } = render(<SpaceSafeBar />)
expect(getByTestId('safe-selector-dropdown')).toBeInTheDocument()
})
it('renders SpaceChainSelector when useIsQualifiedSafe returns true', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
const { getByTestId } = render(<SpaceSafeBar />)
expect(getByTestId('space-chain-selector')).toBeInTheDocument()
})
it('passes items from the hook to SafeSelectorDropdown', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
const { getByTestId } = render(<SpaceSafeBar />)
const dropdown = getByTestId('safe-selector-dropdown')
expect(JSON.parse(dropdown.getAttribute('data-items')!)).toEqual(mockItems)
})
it('passes isError=true to SafeSelectorDropdown when the overview query fails', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
mockUseSpaceSafeSelectorItems.mockReturnValue({
items: [],
selectedItemId: '',
handleItemSelect: jest.fn(),
isError: true,
refetch: jest.fn(),
})
const { getByTestId } = render(<SpaceSafeBar />)
expect(getByTestId('safe-selector-dropdown').getAttribute('data-error')).toBe('true')
})
it('passes selectedItemId, onItemSelect, and onRetry to SafeSelectorDropdown', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
const { getByTestId } = render(<SpaceSafeBar />)
const dropdown = getByTestId('safe-selector-dropdown')
expect(dropdown.getAttribute('data-selected-item-id')).toBe('1:0xSafe1')
expect(dropdown.getAttribute('data-has-on-item-select')).toBe('true')
expect(dropdown.getAttribute('data-has-on-retry')).toBe('true')
})
it('renders nothing when there are no safes but isQualifiedSafe is false', () => {
mockUseIsQualifiedSafe.mockReturnValue(false)
mockUseSpaceSafeSelectorItems.mockReturnValue({
items: [],
selectedItemId: '',
handleItemSelect: jest.fn(),
isError: false,
refetch: jest.fn(),
})
const { container } = render(<SpaceSafeBar />)
expect(container.innerHTML).toBe('')
})
it('renders SpaceBackLink when space data is available', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
mockUseSpaceBackLink.mockReturnValue({
space: { id: 42, name: 'Acme Corp' },
handleBackToSpace: jest.fn(),
})
const { getByTestId } = render(<SpaceSafeBar />)
const backLink = getByTestId('space-back-link')
expect(backLink).toBeInTheDocument()
expect(backLink.getAttribute('data-space-name')).toBe('Acme Corp')
expect(backLink.getAttribute('data-has-on-click')).toBe('true')
})
it('does not render SpaceBackLink when space data is undefined', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
mockUseSpaceBackLink.mockReturnValue({
space: undefined,
handleBackToSpace: jest.fn(),
})
const { queryByTestId } = render(<SpaceSafeBar />)
expect(queryByTestId('space-back-link')).not.toBeInTheDocument()
})
it('renders both SpaceBackLink and SafeSelectorDropdown together', () => {
mockUseIsQualifiedSafe.mockReturnValue(true)
mockUseSpaceBackLink.mockReturnValue({
space: { id: 1, name: 'Test Space' },
handleBackToSpace: jest.fn(),
})
const { getByTestId } = render(<SpaceSafeBar />)
expect(getByTestId('space-back-link')).toBeInTheDocument()
expect(getByTestId('safe-selector-dropdown')).toBeInTheDocument()
})
})
@@ -0,0 +1,33 @@
import SafeSelectorDropdown from '@/features/spaces/components/SafeSelectorDropdown'
import { useIsQualifiedSafe } from '@/features/spaces'
import { useSpaceSafeSelectorItems } from './hooks/useSpaceSafeSelectorItems'
import { useSpaceBackLink } from './hooks/useSpaceBackLink'
import SpaceBackLink from './SpaceBackLink'
import SpaceChainSelector from './SpaceChainSelector'
function SpaceSafeBar() {
const isQualifiedSafe = useIsQualifiedSafe()
const { items, selectedItemId, handleItemSelect, isError, refetch } = useSpaceSafeSelectorItems()
const { space, handleBackToSpace } = useSpaceBackLink()
if (!isQualifiedSafe) return null
return (
<div
className="flex flex-wrap items-center gap-2 px-4 sm:px-6 pt-4 pb-0"
style={{ backgroundColor: 'var(--color-background-main)' }}
>
{space && <SpaceBackLink space={space} onClick={handleBackToSpace} />}
<SpaceChainSelector />
<SafeSelectorDropdown
items={items}
selectedItemId={selectedItemId}
onItemSelect={handleItemSelect}
isError={isError}
onRetry={refetch}
/>
</div>
)
}
export default SpaceSafeBar
@@ -232,6 +232,7 @@ exports[`./TokenIcon.stories WithChainIndicator 1`] = `
height="17.333342000000002"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 17.333342000000002px;"
width="17.333342000000002"
/>
</span>
@@ -73,4 +73,84 @@ describe('WalletInfo', () => {
expect(mockOnboard.disconnectWallet).toHaveBeenCalled()
})
it('calls onSwitch when Switch wallet is clicked', () => {
const onSwitch = jest.fn()
const { getByText } = render(
<WalletInfo
wallet={mockWallet}
onboard={mockOnboard}
addressBook={{}}
handleClose={jest.fn()}
balance={undefined}
currentChainId="1"
onSwitch={onSwitch}
/>,
)
act(() => {
getByText('Switch wallet').click()
})
expect(onSwitch).toHaveBeenCalledTimes(1)
})
it('calls onDisconnect when Disconnect is clicked', () => {
const onDisconnect = jest.fn()
const { getByText } = render(
<WalletInfo
wallet={mockWallet}
onboard={mockOnboard}
addressBook={{}}
handleClose={jest.fn()}
balance={undefined}
currentChainId="1"
onDisconnect={onDisconnect}
/>,
)
act(() => {
getByText('Disconnect').click()
})
expect(onDisconnect).toHaveBeenCalledTimes(1)
})
it('does not throw when onSwitch is not provided', () => {
const { getByText } = render(
<WalletInfo
wallet={mockWallet}
onboard={mockOnboard}
addressBook={{}}
handleClose={jest.fn()}
balance={undefined}
currentChainId="1"
/>,
)
expect(() => {
act(() => {
getByText('Switch wallet').click()
})
}).not.toThrow()
})
it('does not throw when onDisconnect is not provided', () => {
const { getByText } = render(
<WalletInfo
wallet={mockWallet}
onboard={mockOnboard}
addressBook={{}}
handleClose={jest.fn()}
balance={undefined}
currentChainId="1"
/>,
)
expect(() => {
act(() => {
getByText('Disconnect').click()
})
}).not.toThrow()
})
})
@@ -22,9 +22,20 @@ type WalletInfoProps = {
onboard: ReturnType<typeof useOnboard>
addressBook: ReturnType<typeof useAddressBook>
handleClose: () => void
onSwitch?: () => void
onDisconnect?: () => void
}
export const WalletInfo = ({ wallet, balance, currentChainId, onboard, addressBook, handleClose }: WalletInfoProps) => {
export const WalletInfo = ({
wallet,
balance,
currentChainId,
onboard,
addressBook,
handleClose,
onSwitch,
onDisconnect,
}: WalletInfoProps) => {
const [authLogout] = useAuthLogoutV1Mutation()
const dispatch = useAppDispatch()
const chainInfo = useChain(wallet.chainId)
@@ -32,12 +43,14 @@ export const WalletInfo = ({ wallet, balance, currentChainId, onboard, addressBo
const handleSwitchWallet = () => {
if (onboard) {
onSwitch?.()
handleClose()
switchWallet(onboard)
}
}
const handleDisconnect = async () => {
onDisconnect?.()
onboard?.disconnectWallet({
label: wallet.label,
})
@@ -1,5 +1,5 @@
import { createContext, type ReactElement, type ReactNode, useEffect, useState, useMemo } from 'react'
import useOnboard, { type ConnectedWallet, getConnectedWallet } from '@/hooks/wallets/useOnboard'
import useOnboard, { type ConnectedWallet, getConnectedWallet, useIsWalletReady } from '@/hooks/wallets/useOnboard'
import { useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
import { useWeb3ReadOnly } from '@/hooks/wallets/web3ReadOnly'
import { useCurrentChain } from '@/hooks/useChains'
@@ -19,12 +19,14 @@ export type WalletContextType = {
connectedWallet: ConnectedWallet | null
signer: SignerWallet | null
setSignerAddress: (address: string | undefined) => void
isReady: boolean
}
export const WalletContext = createContext<WalletContextType | null>(null)
const WalletProvider = ({ children }: { children: ReactNode }): ReactElement => {
const onboard = useOnboard()
const walletReady = useIsWalletReady()
const currentChain = useCurrentChain()
const web3ReadOnly = useWeb3ReadOnly()
const router = useRouter()
@@ -67,6 +69,7 @@ const WalletProvider = ({ children }: { children: ReactNode }): ReactElement =>
connectedWallet: wallet,
signer,
setSignerAddress,
isReady: !!walletReady,
}}
>
{children}
@@ -28,7 +28,9 @@ const MAX_ASSETS = 4
const NoAssets = () => (
<Paper elevation={0} sx={{ p: 5, textAlign: 'center' }}>
<NoAssetsIcon />
<Box display="flex" justifyContent="center">
<NoAssetsIcon />
</Box>
<Typography mb={0.5} mt={3}>
No assets yet
@@ -1,3 +1,6 @@
/**
* @usedBy features/hypernative/components/HnBanner/HnBannerForCarousel.tsx (type NewsBannerProps)
*/
import React, { createElement, useMemo, useRef, useState } from 'react'
import classnames from 'classnames'
import { Box, Stack } from '@mui/material'
@@ -83,6 +83,7 @@ const PendingTxsList = (): ReactElement | null => {
const { safe, safeLoaded, safeLoading } = useSafeInfo()
const wallet = useWallet()
const queuedTxns = useMemo(() => getLatestTransactions(page?.results), [page?.results])
const recoveryQueue = useRecoveryQueue()
const queueSize = useQueuedTxsLength()
@@ -1,3 +1,5 @@
/* @usedBy features/recovery/components/RecoveryType/index.tsx */
.container {
width: 100%;
padding: 11px 16px;
@@ -1,3 +1,6 @@
/**
* @usedBy pages/balances/index.tsx (StakingBanner, useIsStakingBannerVisible)
*/
import { Typography, Card, SvgIcon, Button, Box, Stack, Link } from '@mui/material'
import css from './styles.module.css'
import StakeIcon from '@/public/images/common/stake.svg'
@@ -1,3 +1,7 @@
/**
* @usedBy features/positions/components/PositionsWidget/index.tsx (WidgetCard)
* @usedBy features/recovery/components/RecoveryHeader/index.tsx (WidgetContainer, WidgetBody)
*/
import type { ReactElement, ReactNode } from 'react'
import styled from '@emotion/styled'
import NextLink from 'next/link'
@@ -31,7 +31,7 @@ const SafeLabsTerms = () => {
const canAccept = acceptTerms && acknowledgeLiability
const logoHref = getLogoLink(router)
const logoHref = getLogoLink()
const handleAcceptAndContinue = () => {
trackEvent({ ...TERMS_EVENTS.ACCEPT_SAFE_LABS_TERMS, label: requestDataTransfer }, { requestDataTransfer })
@@ -58,6 +58,7 @@ const TestWalletContextProvider = ({
provider: null,
isSafe: Boolean(signerAddress),
},
isReady: true,
}
: null
}
@@ -3,6 +3,7 @@ import type { TransactionDetails } from '@safe-global/store/gateway/AUTO_GENERAT
import type { Meta, StoryObj } from '@storybook/react'
import { Paper } from '@mui/material'
import { StoreDecorator } from '@/stories/storeDecorator'
import { RouterDecorator } from '@/stories/routerDecorator'
import { ownerAddress, txInfo } from './mockData'
import SettingsChange from '.'
@@ -15,9 +16,11 @@ const meta = {
(Story) => {
return (
<StoreDecorator initialState={{}}>
<Paper sx={{ padding: 2 }}>
<Story />
</Paper>
<RouterDecorator>
<Paper sx={{ padding: 2 }}>
<Story />
</Paper>
</RouterDecorator>
</StoreDecorator>
)
},
+49
View File
@@ -0,0 +1,49 @@
# shadcn/ui Components
This directory contains UI components from [shadcn/ui](https://ui.shadcn.com/), a collection of re-usable components built with Radix UI and Tailwind CSS.
## ⚠️ Semi-Auto Generated Code
These components are **semi-auto generated** from shadcn/ui templates using the `shadcn` CLI:
```bash
npx shadcn@latest add <component-name>
```
The components are:
- Generated from upstream templates
- Customized for Safe Wallet's design system
- Not manually written from scratch
- May contain intentional code patterns that trigger linters/analyzers
## Code Quality Notes
**These files are excluded from CodeScene analysis** (`.codescene.yml`) because:
- Code duplication is inherent to the shadcn/ui component architecture
- Component complexity reflects upstream patterns, not local technical debt
- False positives from code health tools are expected and acceptable
## Usage
Import components from this directory:
```tsx
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
```
## Customization
While these are generated files, they can be customized as needed. However, be aware that:
- Updates from upstream may require manual merging
- Customizations should be documented in component files
- Consider creating wrapper components for heavy customizations
## Documentation
See individual component stories in `apps/web/src/components/ui/stories/` for usage examples.
For shadcn/ui documentation: https://ui.shadcn.com/docs
+3 -1
View File
@@ -36,6 +36,8 @@ const alertVariants = cva(
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
warning:
'bg-yellow-50 text-yellow-800 border-transparent *:data-[slot=alert-description]:text-yellow-800 *:[svg]:text-current',
},
},
defaultVariants: {
@@ -53,7 +55,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot="alert-title"
className={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
'font-semibold group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className,
)}
{...props}
+1
View File
@@ -33,6 +33,7 @@ const badgeVariants = cva(
destructive:
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
warning: 'bg-yellow-50 text-yellow-800',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
+5 -6
View File
@@ -24,7 +24,7 @@ import { cn } from '@/utils/cn'
*/
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"focus-visible:border-ring cursor-pointer focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-sm border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
@@ -42,13 +42,12 @@ const buttonVariants = cva(
size: {
default:
'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
xs: "h-6 gap-1 px-2 text-xs in-data-[slot=button-group]:rounded-sm has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 px-2.5 in-data-[slot=button-group]:rounded-sm has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-9',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
'icon-xs': "size-6 in-data-[slot=button-group]:rounded-sm [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 in-data-[slot=button-group]:rounded-sm',
'icon-lg': 'size-10',
},
},
+1 -1
View File
@@ -32,7 +32,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border shadow-xs transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[3px] aria-invalid:ring-[3px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
'dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border border-gray-300 shadow-xs transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[3px] aria-invalid:ring-[3px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
+1 -1
View File
@@ -116,7 +116,7 @@ function ComboboxContent({
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
className="isolate z-[var(--z-overlay)]"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
+2 -2
View File
@@ -63,7 +63,7 @@ function ContextMenuContent({
return (
<ContextMenuPrimitive.Portal container={portalContainer}>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
className="isolate z-[var(--z-overlay)] outline-none"
align={align}
alignOffset={alignOffset}
side={side}
@@ -72,7 +72,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-[var(--z-overlay)] max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none',
className,
)}
{...props}
+6 -3
View File
@@ -51,23 +51,26 @@ function DropdownMenuContent({
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
collisionBoundary,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
}: MenuPrimitive.Popup.Props &
Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'collisionBoundary'>) {
const portalContainer = usePortalContainer()
return (
<MenuPrimitive.Portal container={portalContainer}>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
className="isolate z-[var(--z-overlay)] outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...(collisionBoundary !== undefined && { collisionBoundary })}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-[var(--z-overlay)] max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden',
className,
)}
{...props}
+2 -2
View File
@@ -51,12 +51,12 @@ function HoverCardContent({
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
className="isolate z-[var(--z-overlay)]"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-4 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 origin-(--transform-origin) outline-hidden',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-4 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-[var(--z-overlay)] origin-(--transform-origin) outline-hidden',
className,
)}
{...props}
+72
View File
@@ -0,0 +1,72 @@
import { sanitizeInputValue, containsScriptInjection } from './input'
describe('sanitizeInputValue', () => {
it('should remove script tags', () => {
expect(sanitizeInputValue('<script>alert("xss")</script>')).toBe('')
expect(sanitizeInputValue('hello<script>alert(1)</script>world')).toBe('helloworld')
})
it('should remove script tags with attributes', () => {
expect(sanitizeInputValue('<script type="text/javascript">alert(1)</script>')).toBe('')
})
it('should remove unclosed script tags', () => {
expect(sanitizeInputValue('<script>alert(1)')).toBe('')
})
it('should remove inline event handlers', () => {
expect(sanitizeInputValue('<img onerror="alert(1)">')).toBe('<img >')
expect(sanitizeInputValue('<div onclick="steal()">')).toBe('<div >')
expect(sanitizeInputValue('<body onload="malicious()">')).toBe('<body >')
})
it('should handle event handlers with single quotes', () => {
expect(sanitizeInputValue("<img onerror='alert(1)'>")).toBe('<img >')
})
it('should not modify normal text', () => {
expect(sanitizeInputValue('My Safe Wallet')).toBe('My Safe Wallet')
expect(sanitizeInputValue('hello@example.com')).toBe('hello@example.com')
expect(sanitizeInputValue('0x1234abcd')).toBe('0x1234abcd')
})
it('should not modify text with angle brackets in non-script context', () => {
expect(sanitizeInputValue('a < b > c')).toBe('a < b > c')
expect(sanitizeInputValue('5 > 3')).toBe('5 > 3')
})
it('should handle empty string', () => {
expect(sanitizeInputValue('')).toBe('')
})
it('should be case insensitive for script tags', () => {
expect(sanitizeInputValue('<SCRIPT>alert(1)</SCRIPT>')).toBe('')
expect(sanitizeInputValue('<Script>alert(1)</Script>')).toBe('')
})
it('should handle nested patterns that reconstruct after a single pass', () => {
expect(sanitizeInputValue('ononclickclick="steal()"')).toBe('')
expect(sanitizeInputValue('<scr<script></script>ipt>alert(1)</script>')).toBe('')
expect(sanitizeInputValue('ononloadload="malicious()"')).toBe('')
})
})
describe('containsScriptInjection', () => {
it('should detect script tags', () => {
expect(containsScriptInjection('<script>alert(1)</script>')).toBe(true)
expect(containsScriptInjection('hello<script>alert(1)</script>')).toBe(true)
expect(containsScriptInjection('<SCRIPT>alert(1)</SCRIPT>')).toBe(true)
})
it('should detect inline event handlers', () => {
expect(containsScriptInjection('<img onerror="alert(1)">')).toBe(true)
expect(containsScriptInjection('onclick="steal()"')).toBe(true)
})
it('should return false for normal text', () => {
expect(containsScriptInjection('My Safe Wallet')).toBe(false)
expect(containsScriptInjection('hello@example.com')).toBe(false)
expect(containsScriptInjection('0x1234abcd')).toBe(false)
expect(containsScriptInjection('')).toBe(false)
})
})
+96 -11
View File
@@ -1,5 +1,6 @@
import * as React from 'react'
import { Input as InputPrimitive } from '@base-ui/react/input'
import { cleanInputValue, parsePrefixedAddress } from '@safe-global/utils/utils/addresses'
import { cn } from '@/utils/cn'
@@ -20,18 +21,102 @@ import { cn } from '@/utils/cn'
* - `type`, `placeholder`, `disabled`, `className` extends native input props, see Base UI
*/
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
const SCRIPT_TAG_REGEX = /<script[\s>][\s\S]*?(?:<\/script>|$)/gi
const EVENT_HANDLER_REGEX = /\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi
const SCRIPT_INJECTION_ERROR = 'Scripts and event handlers are not allowed'
function containsScriptInjection(value: string): boolean {
SCRIPT_TAG_REGEX.lastIndex = 0
EVENT_HANDLER_REGEX.lastIndex = 0
return SCRIPT_TAG_REGEX.test(value) || EVENT_HANDLER_REGEX.test(value)
}
function sanitizeInputValue(value: string): string {
let previous: string
let sanitized = value
do {
previous = sanitized
SCRIPT_TAG_REGEX.lastIndex = 0
EVENT_HANDLER_REGEX.lastIndex = 0
sanitized = sanitized.replace(SCRIPT_TAG_REGEX, '').replace(EVENT_HANDLER_REGEX, '')
} while (sanitized !== previous)
return sanitized
}
function stripChainPrefix(value: string): string {
const cleaned = cleanInputValue(value)
const { address } = parsePrefixedAddress(cleaned)
return address
}
function Input({
className,
type,
onChange,
onPaste,
error,
address,
...props
}: React.ComponentProps<'input'> & { error?: string; address?: boolean }) {
const [hasScriptInjection, setHasScriptInjection] = React.useState(false)
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
const isInjection = containsScriptInjection(raw)
setHasScriptInjection(isInjection)
if (isInjection) {
const sanitized = sanitizeInputValue(raw)
e.target.value = sanitized
}
onChange?.(e)
},
[onChange],
)
const handlePaste = React.useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
if (address) {
e.preventDefault()
const pasted = e.clipboardData.getData('text')
const stripped = stripChainPrefix(pasted)
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
nativeInputValueSetter?.call(e.currentTarget, stripped)
e.currentTarget.dispatchEvent(new Event('input', { bubbles: true }))
}
onPaste?.(e)
},
[address, onPaste],
)
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
<div className="w-full">
<InputPrimitive
type={type}
data-slot="input"
aria-invalid={hasScriptInjection || !!error || props['aria-invalid'] || undefined}
className={cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
onChange={handleChange}
onPaste={handlePaste}
/>
{hasScriptInjection ? (
<p role="alert" data-slot="field-error" className="mt-1 text-sm text-destructive">
{SCRIPT_INJECTION_ERROR}
</p>
) : error ? (
<p role="alert" data-slot="field-error" className="mt-1 text-sm text-destructive">
{error}
</p>
) : null}
</div>
)
}
export { Input }
export { Input, sanitizeInputValue, containsScriptInjection, SCRIPT_INJECTION_ERROR }
+2 -2
View File
@@ -52,12 +52,12 @@ function PopoverContent({
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
className="isolate z-[var(--z-overlay)]"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-4 rounded-md p-4 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden',
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-4 rounded-md p-4 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-[var(--z-overlay)] w-72 origin-(--transform-origin) outline-hidden',
className,
)}
{...props}
+23 -4
View File
@@ -50,10 +50,12 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
function SelectTrigger({
className,
size = 'default',
iconWrapperClassName,
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: 'sm' | 'default'
iconWrapperClassName?: string
}) {
return (
<SelectPrimitive.Trigger
@@ -66,7 +68,11 @@ function SelectTrigger({
{...props}
>
{children}
<SelectPrimitive.Icon render={<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />} />
<span className={cn('flex items-center shrink-0', iconWrapperClassName)}>
<SelectPrimitive.Icon
render={<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />}
/>
</span>
</SelectPrimitive.Trigger>
)
}
@@ -79,9 +85,20 @@ function SelectContent({
align = 'center',
alignOffset = 0,
alignItemWithTrigger = true,
collisionBoundary,
collisionAvoidance,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'>) {
Pick<
SelectPrimitive.Positioner.Props,
| 'align'
| 'alignOffset'
| 'side'
| 'sideOffset'
| 'alignItemWithTrigger'
| 'collisionBoundary'
| 'collisionAvoidance'
>) {
const portalContainer = usePortalContainer()
return (
<SelectPrimitive.Portal container={portalContainer}>
@@ -91,13 +108,15 @@ function SelectContent({
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
{...(collisionBoundary !== undefined && { collisionBoundary })}
{...(collisionAvoidance !== undefined && { collisionAvoidance })}
className="isolate z-[var(--z-overlay)]"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-md shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-md shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-[var(--z-overlay)] max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
className,
)}
{...props}
+109 -65
View File
@@ -1,6 +1,15 @@
'use client'
import * as React from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type CSSProperties,
type ComponentProps,
} from 'react'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
@@ -79,7 +88,7 @@ import { PanelLeftIcon } from 'lucide-react'
* @example
* ```tsx
* // Controlled sidebar
* const [open, setOpen] = React.useState(false)
* const [open, setOpen] = useState(false)
*
* <SidebarProvider open={open} onOpenChange={setOpen}>
* <Sidebar />
@@ -113,6 +122,22 @@ import { PanelLeftIcon } from 'lucide-react'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
function getSidebarStateFromCookie(fallback: boolean): boolean {
if (typeof document === 'undefined') return fallback
try {
const match = document.cookie.match(
new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`),
)
const value = match?.[1]?.trim()
if (value === 'true') return true
if (value === 'false') return false
} catch {
// ignore - falling back to the default sidebar state
}
return fallback
}
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
@@ -127,10 +152,10 @@ type SidebarContextProps = {
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
@@ -138,48 +163,71 @@ function useSidebar() {
return context
}
/** Shared logic for sidebar open state: desktop (expand/collapse) and mobile (sheet open/closed). Controlled when props are passed (e.g. parent drawer), otherwise internal. Supports both value and updater for toggling. */
function useControlledBoolean(
controlled: boolean | undefined,
onChange: ((value: boolean) => void) | undefined,
defaultValue: boolean,
): [boolean, (value: boolean | ((prev: boolean) => boolean)) => void] {
const [internal, setInternal] = useState(defaultValue)
const value = controlled ?? internal
const setValue = useCallback(
(update: boolean | ((prev: boolean) => boolean)) => {
const next = typeof update === 'function' ? update(value) : update
if (onChange) {
onChange(next)
} else {
setInternal(next)
}
},
[value, onChange],
)
return [value, setValue]
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
openMobile: openMobileProp,
onOpenMobileChange: setOpenMobileProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
}: ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
openMobile?: boolean
onOpenMobileChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
const initialOpen = useMemo(
() => getSidebarStateFromCookie(defaultOpen),
// eslint-disable-next-line react-hooks/exhaustive-deps -- read cookie only once on mount
[],
)
const [openMobile, setOpenMobile] = useControlledBoolean(openMobileProp, setOpenMobileProp, false)
const [open, setOpenBase] = useControlledBoolean(openProp, setOpenProp, initialOpen)
const setOpen = useCallback(
(update: boolean | ((prev: boolean) => boolean)) => {
setOpenBase((prev) => {
const next = typeof update === 'function' ? update(prev) : update
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
return next
})
},
[setOpenProp, open],
[setOpenBase],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
const toggleSidebar = useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
@@ -195,7 +243,7 @@ function SidebarProvider({
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
const contextValue = useMemo<SidebarContextProps>(
() => ({
state,
open,
@@ -217,7 +265,7 @@ function SidebarProvider({
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
} as CSSProperties
}
className={cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className)}
{...props}
@@ -246,7 +294,7 @@ function Sidebar({
className,
children,
...props
}: React.ComponentProps<'div'> & {
}: ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
@@ -277,11 +325,11 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
className="bg-sidebar text-sidebar-foreground z-[var(--z-sidebar)] w-(--sidebar-width) !border-r-0 p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
} as CSSProperties
}
side={side}
>
@@ -319,7 +367,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
'fixed inset-y-0 z-[var(--z-sidebar)] hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
@@ -343,7 +391,7 @@ function Sidebar({
)
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
function SidebarTrigger({ className, onClick, ...props }: ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
@@ -365,7 +413,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
function SidebarRail({ className, ...props }: ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
@@ -390,7 +438,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
function SidebarInset({ className, ...props }: ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
@@ -403,7 +451,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
)
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
@@ -414,29 +462,29 @@ function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarHeader({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('gap-2 p-2 flex flex-col', className)}
className={cn('gap-2 p-2 flex flex-col group-data-[collapsible=icon]:items-center', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('gap-2 p-2 flex flex-col', className)}
className={cn('gap-2 p-2 flex flex-col group-data-[collapsible=icon]:items-center', className)}
{...props}
/>
)
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
@@ -447,13 +495,13 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarContent({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'no-scrollbar gap-2 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden',
'no-scrollbar gap-2 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden group-data-[collapsible=icon]:items-center',
className,
)}
{...props}
@@ -461,22 +509,18 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroup({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('p-2 relative flex w-full min-w-0 flex-col', className)}
className={cn('p-2 relative flex w-full min-w-0 flex-col group-data-[collapsible=icon]:items-center', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<'div'> & ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
@@ -500,7 +544,7 @@ function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
}: useRender.ComponentProps<'button'> & ComponentProps<'button'>) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
@@ -520,7 +564,7 @@ function SidebarGroupAction({
})
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroupContent({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
@@ -531,18 +575,18 @@ function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenu({ className, ...props }: ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('gap-1 flex w-full min-w-0 flex-col', className)}
className={cn('gap-1 flex w-full min-w-0 flex-col group-data-[collapsible=icon]:items-center', className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuItem({ className, ...props }: ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
@@ -554,7 +598,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
}
const sidebarMenuButtonVariants = cva(
'ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0',
'ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:[&>span:last-child]:hidden focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
@@ -584,9 +628,9 @@ function SidebarMenuButton({
className,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
ComponentProps<'button'> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
tooltip?: string | ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
@@ -630,7 +674,7 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
ComponentProps<'button'> & {
showOnHover?: boolean
}) {
return useRender({
@@ -654,7 +698,7 @@ function SidebarMenuAction({
})
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarMenuBadge({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
@@ -672,11 +716,11 @@ function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
}: ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
const [width] = useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
@@ -694,14 +738,14 @@ function SidebarMenuSkeleton({
style={
{
'--skeleton-width': width,
} as React.CSSProperties
} as CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenuSub({ className, ...props }: ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
@@ -715,7 +759,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
)
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuSubItem({ className, ...props }: ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
@@ -733,7 +777,7 @@ function SidebarMenuSubButton({
className,
...props
}: useRender.ComponentProps<'a'> &
React.ComponentProps<'a'> & {
ComponentProps<'a'> & {
size?: 'sm' | 'md'
isActive?: boolean
}) {
@@ -0,0 +1,140 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Typography } from '../typography'
const meta = {
title: 'UI/Typography',
component: Typography,
decorators: [
(Story) => (
<div style={{ backgroundColor: 'var(--color-background-default)', padding: '2rem', minHeight: '100vh' }}>
<Story />
</div>
),
],
argTypes: {
variant: {
control: 'select',
options: [
'h1',
'h2',
'h3',
'h4',
'paragraph',
'paragraph-medium',
'paragraph-bold',
'paragraph-small',
'paragraph-small-medium',
'paragraph-mini',
'paragraph-mini-medium',
'paragraph-mini-bold',
'code',
],
},
align: {
control: 'select',
options: ['left', 'center', 'right'],
},
color: {
control: 'select',
options: ['default', 'muted'],
},
},
} satisfies Meta<typeof Typography>
export default meta
type Story = StoryObj<typeof meta>
/**
* All Figma typography variants. Default view.
*/
export const Default: Story = {
tags: ['!chromatic'],
args: {
align: 'left',
},
parameters: {
controls: { exclude: ['variant'] },
},
render: (args) => (
<div className="space-y-8">
<div>
<Typography {...args} variant="h1">
Heading 1 48px Semibold
</Typography>
</div>
<div>
<Typography {...args} variant="h2">
Heading 2 30px Semibold
</Typography>
</div>
<div>
<Typography {...args} variant="h3">
Heading 3 24px Semibold
</Typography>
</div>
<div>
<Typography {...args} variant="h4">
Heading 4 20px Semibold
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph">
Paragraph 16px Regular
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-medium">
Paragraph medium 16px Medium
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-bold">
Paragraph bold 16px Semibold
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-small">
Paragraph small 14px Regular
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-small-medium">
Paragraph small medium 14px Medium
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-mini">
Paragraph mini 12px Regular
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-mini-medium">
Paragraph mini medium 12px Medium
</Typography>
</div>
<div>
<Typography {...args} variant="paragraph-mini-bold">
Paragraph mini bold 12px Semibold
</Typography>
</div>
<div>
<Typography {...args} variant="code">
Monospaced 16px
</Typography>
</div>
</div>
),
}
/**
* Interactive playground to try variant, align, and text.
*/
export const Playground: Story = {
args: {
variant: 'paragraph',
align: 'left',
color: 'default',
children:
'The king, seeing how much happier his subjects were, realized the error of his ways and repealed the joke tax.',
},
}
+2 -2
View File
@@ -37,14 +37,14 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent shadow-xs focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
'data-checked:bg-primary data-unchecked:bg-input data-unchecked:border-border focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent shadow-xs focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
className="data-unchecked:bg-foreground data-checked:bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
/>
</SwitchPrimitive.Root>
)
+3 -3
View File
@@ -59,18 +59,18 @@ function TooltipContent({
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
className="isolate z-[var(--z-overlay)]"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)',
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-[var(--z-overlay)] w-fit max-w-xs origin-(--transform-origin)',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 bg-foreground fill-foreground z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 bg-foreground fill-foreground z-[var(--z-overlay)] data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
+89
View File
@@ -0,0 +1,89 @@
import type React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/utils/cn'
/**
* Typography component aligned with Figma Obra-shadcn-ui--safe-.
*
* In Figma, typography is defined by styles (heading 1, paragraph/regular, etc.), not by components.
* We use a component with variants in code because it centralizes styles and makes usage easier.
*
* Figma style variant mapping: see .claude/skills/design.figma-to-code/reference.md
*
* @see https://www.figma.com/design/trBVcpjZslO63zxiNUI9io/Obra-shadcn-ui--safe-?node-id=310-257309
*/
const typographyVariants = cva('m-0', {
variants: {
variant: {
h1: 'scroll-m-20 text-[48px] font-semibold leading-[48px] tracking-[-0.015em] text-balance',
h2: 'scroll-m-20 text-[30px] font-semibold leading-[30px] tracking-[-0.01em]',
h3: 'scroll-m-20 text-2xl font-semibold leading-[28.8px] tracking-[-0.01em]',
h4: 'scroll-m-20 text-xl font-semibold leading-6 tracking-normal',
paragraph: 'text-base leading-6 font-normal',
'paragraph-medium': 'text-base leading-6 font-medium',
'paragraph-bold': 'text-base leading-6 font-semibold',
'paragraph-small': 'text-sm leading-5 font-normal',
'paragraph-small-medium': 'text-sm leading-5 font-medium',
'paragraph-mini': 'text-xs leading-4 font-normal',
'paragraph-mini-medium': 'text-xs leading-4 font-medium',
'paragraph-mini-bold': 'text-xs leading-4 font-semibold',
code: 'font-mono text-base leading-6 font-normal',
},
align: {
left: '',
center: 'block w-full text-center',
right: 'block w-full text-right',
},
color: {
default: '',
muted: 'text-muted-foreground',
},
},
defaultVariants: {
variant: 'paragraph',
align: 'left',
color: 'default',
},
})
const variantElementMap = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
paragraph: 'p',
'paragraph-medium': 'p',
'paragraph-bold': 'p',
'paragraph-small': 'span',
'paragraph-small-medium': 'span',
'paragraph-mini': 'span',
'paragraph-mini-medium': 'span',
'paragraph-mini-bold': 'span',
code: 'code',
} as const
interface TypographyProps
extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,
VariantProps<typeof typographyVariants> {}
function Typography({
variant = 'paragraph',
align = 'left',
color = 'default',
className,
...props
}: TypographyProps) {
const Tag = variantElementMap[variant ?? 'paragraph'] as React.ElementType
return (
<Tag
data-slot="typography"
data-variant={variant}
className={cn(typographyVariants({ variant, align, color }), className)}
{...props}
/>
)
}
export { Typography, typographyVariants }
@@ -1,26 +1,34 @@
import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet'
import useWallet from '@/hooks/wallets/useWallet'
import { Box, Button, Typography } from '@mui/material'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import EthHashInfo from '@/components/common/EthHashInfo'
import WalletIcon from '@/components/common/WalletIcon'
import { useEffect, useState } from 'react'
const WalletLogin = ({
onLogin,
onContinue,
buttonText,
fullWidth,
}: {
interface WalletLoginProps {
onLogin: () => void
onContinue: () => void
buttonText?: string
fullWidth?: boolean
}) => {
isLoading?: boolean
}
const WalletLogin = ({ onLogin, onContinue, buttonText, fullWidth, isLoading }: WalletLoginProps) => {
const wallet = useWallet()
const connectWallet = useConnectWallet()
const [hasConnectedWallet, setHasConnectedWallet] = useState(false)
const onConnectWallet = () => {
connectWallet()
onLogin()
useEffect(() => {
if (hasConnectedWallet) {
onLogin()
setHasConnectedWallet(false)
}
}, [hasConnectedWallet])
const onConnectWallet = async () => {
const wallets = await connectWallet()
setHasConnectedWallet(!!wallets?.length)
}
if (wallet !== null) {
@@ -32,18 +40,29 @@ const WalletLogin = ({
fullWidth={fullWidth}
style={{ color: '#fff', background: '#121312' }}
data-testid="continue-with-wallet-btn"
disabled={isLoading}
>
<Box justifyContent="space-between" display="flex" flexDirection="row" alignItems="center" gap={1}>
<Box display="flex" flexDirection="column" alignItems="flex-start">
<Typography variant="subtitle2" fontWeight={700}>
{buttonText || 'Continue with'} {wallet.label}
</Typography>
{wallet.address && (
<EthHashInfo address={wallet.address} shortAddress avatarSize={16} showName={false} copyAddress={false} />
)}
{isLoading ? (
<CircularProgress size={20} sx={{ color: '#fff' }} />
) : (
<Box justifyContent="space-between" display="flex" flexDirection="row" alignItems="center" gap={1}>
<Box display="flex" flexDirection="column" alignItems="flex-start">
<Typography variant="subtitle2" fontWeight={700}>
{buttonText || 'Continue with'} {wallet.label}
</Typography>
{wallet.address && (
<EthHashInfo
address={wallet.address}
shortAddress
avatarSize={16}
showName={false}
copyAddress={false}
/>
)}
</Box>
{wallet.icon && <WalletIcon icon={wallet.icon} provider={wallet.label} width={24} height={24} />}
</Box>
{wallet.icon && <WalletIcon icon={wallet.icon} provider={wallet.label} width={24} height={24} />}
</Box>
)}
</Button>
)
}
@@ -67,7 +67,7 @@ describe('WalletLogin', () => {
})
await waitFor(() => {
expect(result.getByText('Connect wallet')).toBeInTheDocument()
expect(result.findByText(shortenAddress(walletAddress))).resolves.toBeDefined()
})
})
@@ -76,7 +76,7 @@ describe('WalletLogin', () => {
const mockOnContinue = jest.fn()
jest.spyOn(useWallet, 'default').mockReturnValue(null)
jest.spyOn(useConnectWallet, 'default').mockReturnValue(jest.fn().mockReturnValue([]))
jest.spyOn(useConnectWallet, 'default').mockReturnValue(jest.fn().mockReturnValue([{}]))
const result = render(<WalletLogin onLogin={mockOnLogin} onContinue={mockOnContinue} />)
@@ -0,0 +1,243 @@
import { renderHook, act } from '@/tests/test-utils'
import { useHomeAuth } from '../useHomeAuth'
import * as store from '@/store'
import * as siweModule from '@/services/siwe/useSiwe'
import * as analytics from '@/services/analytics'
import * as exceptionsModule from '@/services/exceptions'
import { setAuthenticated } from '@/store/authSlice'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
jest.mock('@/services/analytics', () => ({
...jest.requireActual('@/services/analytics'),
trackEvent: jest.fn(),
}))
jest.mock('@/services/exceptions', () => ({
...jest.requireActual('@/services/exceptions'),
logError: jest.fn(),
}))
const mockSignIn = jest.fn()
const mockDispatch = jest.fn()
jest.mock('@/services/siwe/useSiwe', () => ({
useSiwe: jest.fn(() => ({
signIn: mockSignIn,
loading: false,
})),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface SetupOptions {
isAuthenticated?: boolean
loading?: boolean
}
const setupMocks = ({ isAuthenticated = false, loading = false }: SetupOptions = {}) => {
jest.spyOn(store, 'useAppDispatch').mockReturnValue(mockDispatch)
jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => {
const fakeState = {
auth: {
sessionExpiresAt: isAuthenticated ? Date.now() + 86400000 : null,
lastUsedSpace: null,
isStoreHydrated: true,
},
}
return selector(fakeState as unknown as store.RootState)
})
;(siweModule.useSiwe as jest.Mock).mockReturnValue({
signIn: mockSignIn,
loading,
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useHomeAuth', () => {
const mockOnSuccess = jest.fn()
const mockOnError = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
jest.setSystemTime(new Date('2025-06-15T12:00:00Z'))
})
afterEach(() => {
jest.useRealTimers()
})
it('should return performAuth function and loading state', () => {
setupMocks()
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
expect(result.current.performAuth).toBeDefined()
expect(typeof result.current.performAuth).toBe('function')
expect(result.current.loading).toBe(false)
})
it('should return loading true when SIWE is loading', () => {
setupMocks({ loading: true })
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
expect(result.current.loading).toBe(true)
})
it('should not proceed when loading is true', async () => {
setupMocks({ loading: true })
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(mockSignIn).not.toHaveBeenCalled()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// User is not authenticated
// -----------------------------------------------------------------------
describe('when user is not authenticated', () => {
it('should sign in and call onSuccess on successful SIWE auth', async () => {
setupMocks({ isAuthenticated: false })
mockSignIn.mockResolvedValue({ data: { token: 'abc' } })
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(analytics.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: SPACE_EVENTS.SIGN_IN_BUTTON.action,
}),
)
expect(mockSignIn).toHaveBeenCalled()
expect(mockDispatch).toHaveBeenCalledWith(setAuthenticated(expect.any(Number)))
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should not call onSuccess when signIn returns null', async () => {
setupMocks({ isAuthenticated: false })
mockSignIn.mockResolvedValue(null)
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(mockSignIn).toHaveBeenCalled()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
it('should not call onSuccess when signIn returns undefined', async () => {
setupMocks({ isAuthenticated: false })
mockSignIn.mockResolvedValue(undefined)
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(mockSignIn).toHaveBeenCalled()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
it('should throw and handle error when signIn returns an error', async () => {
setupMocks({ isAuthenticated: false })
const signInError = new Error('User rejected')
mockSignIn.mockResolvedValue({ error: signInError })
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess, onError: mockOnError }))
await act(async () => {
await result.current.performAuth()
})
expect(mockOnSuccess).not.toHaveBeenCalled()
expect(exceptionsModule.logError).toHaveBeenCalled()
expect(mockOnError).toHaveBeenCalledWith(signInError)
// showNotification returns a thunk, so dispatch receives a function
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function))
})
it('should handle error when signIn throws', async () => {
setupMocks({ isAuthenticated: false })
const signInError = new Error('Network error')
mockSignIn.mockRejectedValue(signInError)
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess, onError: mockOnError }))
await act(async () => {
await result.current.performAuth()
})
expect(mockOnSuccess).not.toHaveBeenCalled()
expect(exceptionsModule.logError).toHaveBeenCalled()
expect(mockOnError).toHaveBeenCalledWith(signInError)
})
it('should show error notification even without onError callback', async () => {
setupMocks({ isAuthenticated: false })
mockSignIn.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(exceptionsModule.logError).toHaveBeenCalled()
// showNotification returns a thunk, so dispatch receives a function
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function))
})
})
// -----------------------------------------------------------------------
// User is already authenticated
// -----------------------------------------------------------------------
describe('when user is already authenticated', () => {
it('should skip sign-in and call onSuccess directly', async () => {
setupMocks({ isAuthenticated: true })
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(mockSignIn).not.toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should not track sign-in analytics event when already authenticated', async () => {
setupMocks({ isAuthenticated: true })
const { result } = renderHook(() => useHomeAuth({ onSuccess: mockOnSuccess }))
await act(async () => {
await result.current.performAuth()
})
expect(analytics.trackEvent).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,228 @@
import { renderHook, act } from '@/tests/test-utils'
import { useSignInRedirect } from '../useSignInRedirect'
import * as router from 'next/router'
import * as store from '@/store'
import { AppRoutes } from '@/config/routes'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockPush = jest.fn(() => Promise.resolve(true))
jest.mock('next/router', () => ({
useRouter: jest.fn(() => ({
pathname: '/welcome',
query: {},
push: mockPush,
})),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface DefaultProps {
spacesAmount: number
inviteAmount: number
isSpacesLoading: boolean
error: Error | undefined
}
const defaultProps: DefaultProps = {
spacesAmount: 0,
inviteAmount: 0,
isSpacesLoading: false,
error: undefined,
}
interface SetupOptions {
isAuthenticated?: boolean
routerQuery?: Record<string, string>
props?: Partial<DefaultProps>
}
const setupMocks = ({ isAuthenticated = true, routerQuery = {} }: SetupOptions = {}) => {
;(router.useRouter as jest.Mock).mockReturnValue({
pathname: '/welcome',
query: routerQuery,
push: mockPush,
})
jest.spyOn(store, 'useAppSelector').mockImplementation((selector) => {
const fakeState = {
auth: {
sessionExpiresAt: isAuthenticated ? Date.now() + 86400000 : null,
lastUsedSpace: null,
isStoreHydrated: true,
},
}
return selector(fakeState as unknown as store.RootState)
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useSignInRedirect', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return setHasSignedIn and redirectLoading', () => {
setupMocks()
const { result } = renderHook(() => useSignInRedirect(defaultProps))
expect(result.current.setHasSignedIn).toBeDefined()
expect(typeof result.current.setHasSignedIn).toBe('function')
expect(result.current.redirectLoading).toBe(false)
})
// -----------------------------------------------------------------------
// Redirect for new users (no spaces, no invites)
// -----------------------------------------------------------------------
describe('when user is new (no spaces, no invites)', () => {
it('should redirect to create space page after sign-in', async () => {
setupMocks()
const { result } = renderHook(() => useSignInRedirect(defaultProps))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).toHaveBeenCalledWith({
pathname: AppRoutes.welcome.createSpace,
query: {},
})
expect(result.current.redirectLoading).toBe(true)
})
it('should preserve query parameters when redirecting to create space', async () => {
setupMocks({ routerQuery: { chain: 'eth' } })
const { result } = renderHook(() => useSignInRedirect(defaultProps))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).toHaveBeenCalledWith({
pathname: AppRoutes.welcome.createSpace,
query: { chain: 'eth' },
})
})
})
// -----------------------------------------------------------------------
// Redirect when spaces endpoint returns 404
// -----------------------------------------------------------------------
describe('when spaces endpoint returns 404', () => {
it('should redirect to create space page', async () => {
setupMocks()
const notFoundError = { status: 404, data: 'Not Found' } as unknown as Error
const { result } = renderHook(() => useSignInRedirect({ ...defaultProps, error: notFoundError }))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).toHaveBeenCalledWith({
pathname: AppRoutes.welcome.createSpace,
query: {},
})
})
})
// -----------------------------------------------------------------------
// No redirect when not signed in
// -----------------------------------------------------------------------
describe('when user has not signed in yet', () => {
it('should not redirect if hasSignedIn is false', () => {
setupMocks()
renderHook(() => useSignInRedirect(defaultProps))
expect(mockPush).not.toHaveBeenCalled()
})
})
// -----------------------------------------------------------------------
// No redirect when user has spaces
// -----------------------------------------------------------------------
describe('when user has existing spaces', () => {
it('should not redirect to create space page', async () => {
setupMocks()
const { result } = renderHook(() => useSignInRedirect({ ...defaultProps, spacesAmount: 2 }))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).not.toHaveBeenCalled()
})
})
// -----------------------------------------------------------------------
// No redirect when user has invites
// -----------------------------------------------------------------------
describe('when user has pending invites', () => {
it('should not redirect to create space page', async () => {
setupMocks()
const { result } = renderHook(() => useSignInRedirect({ ...defaultProps, inviteAmount: 1 }))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).not.toHaveBeenCalled()
})
})
// -----------------------------------------------------------------------
// No redirect on non-404 errors
// -----------------------------------------------------------------------
describe('when there is a non-404 error', () => {
it('should not redirect', async () => {
setupMocks()
const serverError = { status: 500, data: 'Server Error' } as unknown as Error
const { result } = renderHook(() => useSignInRedirect({ ...defaultProps, error: serverError }))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).not.toHaveBeenCalled()
})
})
// -----------------------------------------------------------------------
// No redirect while spaces are loading
// -----------------------------------------------------------------------
describe('when spaces are still loading', () => {
it('should not redirect', async () => {
setupMocks()
const { result } = renderHook(() => useSignInRedirect({ ...defaultProps, isSpacesLoading: true }))
await act(async () => {
result.current.setHasSignedIn(true)
})
expect(mockPush).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,77 @@
import { useCallback } from 'react'
import { useAppDispatch, useAppSelector } from '@/store'
import { OVERVIEW_LABELS, trackEvent } from '@/services/analytics'
import { SPACE_EVENTS } from '@/services/analytics/events/spaces'
import { useSiwe } from '@/services/siwe/useSiwe'
import { isAuthenticated, setAuthenticated } from '@/store/authSlice'
import { logError } from '@/services/exceptions'
import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes'
import { showNotification } from '@/store/notificationsSlice'
import type { AppDispatch } from '@/store'
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
interface UseSIWEAuthArgs {
onSuccess: () => void
onError?: (error: Error) => void
skipSiwe?: boolean
}
/**
* Attempts SIWE sign-in and dispatches the authenticated state.
* Returns `true` if authentication succeeded, `false` if the user
* rejected or the provider was unavailable.
*/
const performSignIn = async (signIn: ReturnType<typeof useSiwe>['signIn'], dispatch: AppDispatch): Promise<boolean> => {
trackEvent({ ...SPACE_EVENTS.SIGN_IN_BUTTON, label: OVERVIEW_LABELS.welcome_page })
const result = await signIn()
if (result?.error) {
throw result.error
}
if (!result) return false
dispatch(setAuthenticated(Date.now() + ONE_DAY_IN_MS))
return true
}
const handleAuthError = (error: unknown, dispatch: AppDispatch, onError?: (error: Error) => void) => {
logError(ErrorCodes._640)
onError?.(error as Error)
dispatch(
showNotification({
message: 'Something went wrong while trying to sign in',
variant: 'error',
groupKey: 'sign-in-failed',
}),
)
}
export const useHomeAuth = ({ onSuccess, onError, skipSiwe }: UseSIWEAuthArgs) => {
const dispatch = useAppDispatch()
const isUserAuthenticated = useAppSelector(isAuthenticated)
const { signIn, loading } = useSiwe()
const performAuth = useCallback(async () => {
if (loading) return
try {
if (!isUserAuthenticated && !skipSiwe) {
const didSignIn = await performSignIn(signIn, dispatch)
if (!didSignIn) return
}
onSuccess()
} catch (error) {
handleAuthError(error, dispatch, onError)
}
}, [isUserAuthenticated, signIn, dispatch, onSuccess, onError, loading])
return {
performAuth,
loading,
}
}
@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { AppRoutes } from '@/config/routes'
import { useAppSelector } from '@/store'
import { isAuthenticated } from '@/store/authSlice'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
type RtkError = FetchBaseQueryError | SerializedError
interface UseSignInRedirectProps {
spacesAmount: number
inviteAmount: number
isSpacesLoading: boolean
error: RtkError | undefined
}
const hasNotFoundSpaces = (error?: RtkError) => {
return error && 'status' in error && error.status === 404
}
export const useSignInRedirect = ({ spacesAmount, inviteAmount, isSpacesLoading, error }: UseSignInRedirectProps) => {
const [hasSignedIn, setHasSignedIn] = useState(false)
const router = useRouter()
const [redirectLoading, setRedirectLoading] = useState(false)
const isUserSignedIn = useAppSelector(isAuthenticated)
useEffect(() => {
const isNewUser = !inviteAmount && !isSpacesLoading && spacesAmount === 0 && isUserSignedIn
if (error && !hasNotFoundSpaces(error)) return
if (hasSignedIn && (isNewUser || hasNotFoundSpaces(error))) {
setRedirectLoading(true)
router.push({ pathname: AppRoutes.welcome.createSpace, query: router.query })
}
}, [hasSignedIn, isSpacesLoading, spacesAmount, inviteAmount, isUserSignedIn, error])
return { setHasSignedIn, redirectLoading }
}
@@ -1,40 +1,23 @@
import { AppRoutes } from '@/config/routes'
import { Paper, Typography, Divider, Box, Link, Button } from '@mui/material'
import css from './styles.module.css'
import { useRouter } from 'next/router'
import { CREATE_SAFE_EVENTS } from '@/services/analytics/events/createLoadSafe'
import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics'
import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics'
import useWallet from '@/hooks/wallets/useWallet'
import { useHasSafes } from '@/features/myAccounts'
import Track from '@/components/common/Track'
import { useCallback, useEffect, useState } from 'react'
import WalletLogin from './WalletLogin'
import { useHomeAuth } from './hooks/useHomeAuth'
import { useRouter } from 'next/router'
const WelcomeLogin = () => {
const router = useRouter()
const wallet = useWallet()
const { isLoaded, hasSafes } = useHasSafes()
const [shouldRedirect, setShouldRedirect] = useState(false)
const router = useRouter()
const redirect = useCallback(() => {
if (wallet) {
if (isLoaded && !hasSafes) {
trackEvent(CREATE_SAFE_EVENTS.OPEN_SAFE_CREATION)
router.push({ pathname: AppRoutes.newSafe.create, query: router.query })
} else {
router.push({ pathname: AppRoutes.welcome.accounts, query: router.query })
}
}
}, [hasSafes, isLoaded, router, wallet])
const onLogin = useCallback(() => {
setShouldRedirect(true)
}, [])
useEffect(() => {
if (!shouldRedirect) return
redirect()
}, [redirect, shouldRedirect])
const { performAuth, loading } = useHomeAuth({
onSuccess: () => {
router.push({ pathname: AppRoutes.welcome.accounts, query: { ...router.query } })
},
skipSiwe: true,
})
return (
<Paper className={css.loginCard} data-testid="welcome-login" style={{ background: '#fff' }}>
@@ -51,7 +34,7 @@ const WelcomeLogin = () => {
<Box className={css.fullWidth}>
<Track {...OVERVIEW_EVENTS.OPEN_ONBOARD} label={OVERVIEW_LABELS.welcome_page}>
<WalletLogin onLogin={onLogin} onContinue={redirect} fullWidth />
<WalletLogin onLogin={performAuth} onContinue={performAuth} fullWidth isLoading={loading} />
</Track>
</Box>
@@ -62,19 +45,12 @@ const WelcomeLogin = () => {
or
</Typography>
</Divider>
{hasSafes ? (
<Link href={AppRoutes.welcome.accounts}>
<Button disableElevation size="small">
View my accounts
</Button>
</Link>
) : (
<Link href={AppRoutes.newSafe.load} className={css.watchViewAccountLink}>
<Button disableElevation size="small">
Watch any account
</Button>
</Link>
)}
<Link href={AppRoutes.newSafe.load} className={css.watchViewAccountLink}>
<Button disableElevation size="small">
Watch any account
</Button>
</Link>
</>
)}
</Box>
+8
View File
@@ -60,6 +60,9 @@ export const AppRoutes = {
members: '/spaces/members',
index: '/spaces',
addressBook: '/spaces/address-book',
createSpace: '/spaces/create-space',
transactions: '/spaces/transactions',
security: '/spaces/security',
},
transactions: {
tx: '/transactions/tx',
@@ -73,6 +76,11 @@ export const AppRoutes = {
spaces: '/welcome/spaces',
index: '/welcome',
accounts: '/welcome/accounts',
// Onboarding routes
createSpace: '/welcome/create-space',
selectSafes: '/welcome/select-safes',
inviteMembers: '/welcome/invite-members',
},
}
@@ -0,0 +1,81 @@
import { type ReactElement, useMemo } from 'react'
import { useRouter } from 'next/router'
import { ChevronRight } from 'lucide-react'
import useBalances from '@/hooks/useBalances'
import { useVisibleAssets } from '@/components/balances/AssetsTable/useHideAssets'
import { useAppSelector } from '@/store'
import { selectCurrency } from '@/store/settingsSlice'
import { formatVisualAmount } from '@safe-global/utils/utils/formatters'
import { formatCurrency } from '@safe-global/utils/utils/formatNumber'
import { AppRoutes } from '@/config/routes'
import SafeWidget from '@/features/spaces/components/SafeWidget'
import { Button } from '@/components/ui/button'
import TokenIcon from '@/components/common/TokenIcon'
const MAX_ASSETS = 3
const AssetsList = (): ReactElement => {
const router = useRouter()
const { loading, balances } = useBalances()
const visibleAssets = useVisibleAssets()
const currency = useAppSelector(selectCurrency)
const items = useMemo(() => {
return visibleAssets.filter((item) => item.balance !== '0').slice(0, MAX_ASSETS)
}, [visibleAssets])
const remainingCount = useMemo(() => {
const total = visibleAssets.filter((item) => item.balance !== '0').length
return total > MAX_ASSETS ? total - MAX_ASSETS : undefined
}, [visibleAssets])
const isLoading = loading || !balances.fiatTotal
const handleViewAll = () => {
router.push({ pathname: AppRoutes.balances.index, query: { safe: router.query.safe } })
}
return (
<SafeWidget
title="Assets"
action={
<Button variant="ghost" size="icon-sm" onClick={handleViewAll}>
<ChevronRight className="size-6" />
</Button>
}
>
{isLoading ? (
Array.from({ length: MAX_ASSETS }).map((_, i) => <SafeWidget.ItemSkeleton key={i} />)
) : items.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">No assets</p>
) : (
items.map((item) => (
<SafeWidget.Item
key={item.tokenInfo.address}
label={item.tokenInfo.name}
info={`${formatVisualAmount(item.balance, item.tokenInfo.decimals)} ${item.tokenInfo.symbol}`}
startNode={
<div className="flex size-10 shrink-0 items-center justify-center">
<TokenIcon
logoUri={item.tokenInfo.logoUri || undefined}
tokenSymbol={item.tokenInfo.symbol}
size={32}
/>
</div>
}
actionNode={
<span className="text-sm font-medium text-muted-foreground">
{formatCurrency(item.fiatBalance, currency)}
</span>
}
/>
))
)}
{!isLoading && items.length > 0 && (
<SafeWidget.Footer count={remainingCount} text="View all assets" onClick={handleViewAll} />
)}
</SafeWidget>
)
}
export default AssetsList
+5
View File
@@ -0,0 +1,5 @@
import type AssetsList from './components/AssetsList'
export interface AssetsContract {
AssetsList: typeof AssetsList
}
+4
View File
@@ -0,0 +1,4 @@
import type { AssetsContract } from './contract'
import AssetsList from './components/AssetsList'
export default { AssetsList } satisfies AssetsContract
+8
View File
@@ -0,0 +1,8 @@
import type { FeatureHandle } from '@/features/__core__'
import type { AssetsContract } from './contract'
export const AssetsFeature: FeatureHandle<AssetsContract> = {
name: 'assets',
useIsEnabled: () => true,
load: () => import('./feature'),
}
@@ -21,7 +21,7 @@ import ExternalLink from '@/components/common/ExternalLink'
import { useRouter } from 'next/router'
import ChainIndicator from '@/components/common/ChainIndicator'
import { type Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useCompatibleNetworks } from '@safe-global/utils/features/multichain/hooks/useCompatibleNetworks'
import { MULTICHAIN_HELP_ARTICLE } from '@/config/constants'
import { PayMethod } from '@safe-global/utils/features/counterfactual/types'
@@ -44,7 +44,7 @@ const ReplaySafeDialog = ({
chainId: chain?.chainId || '',
},
})
const { handleSubmit, formState } = formMethods
const { handleSubmit, formState, reset } = formMethods
const router = useRouter()
const addressBook = useAddressBook()
@@ -54,6 +54,12 @@ const ReplaySafeDialog = ({
const [creationError, setCreationError] = useState<Error>()
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
useEffect(() => {
if (chain?.chainId) {
reset({ chainId: chain.chainId })
}
}, [chain?.chainId, reset])
// Load some data
const [safeCreationData, safeCreationDataError, safeCreationDataLoading] = safeCreationResult
@@ -252,12 +258,14 @@ const ReplaySafeDialog = ({
export const CreateSafeOnNewChain = ({
safeAddress,
deployedChainIds,
defaultChainId,
...props
}: Omit<
ReplaySafeDialogProps,
'safeCreationResult' | 'replayableChains' | 'chain' | 'isUnsupportedSafeCreationVersion'
> & {
deployedChainIds: string[]
defaultChainId?: string
}) => {
const { configs } = useChains()
const deployedChains = useMemo(
@@ -276,12 +284,18 @@ export const CreateSafeOnNewChain = ({
[allCompatibleChains, deployedChainIds],
)
const preselectedChain = useMemo(
() => (defaultChainId ? replayableChains?.find((c) => c.chainId === defaultChainId) : undefined),
[defaultChainId, replayableChains],
)
return (
<ReplaySafeDialog
safeCreationResult={safeCreationResult}
replayableChains={replayableChains}
safeAddress={safeAddress}
isUnsupportedSafeCreationVersion={isUnsupportedSafeCreationVersion}
chain={preselectedChain}
{...props}
/>
)
@@ -20,6 +20,7 @@ exports[`./NetworkLogosList.stories FourNetworks 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -32,6 +33,7 @@ exports[`./NetworkLogosList.stories FourNetworks 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/137/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -80,6 +82,7 @@ exports[`./NetworkLogosList.stories ManyNetworksWithHasMore 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -92,6 +95,7 @@ exports[`./NetworkLogosList.stories ManyNetworksWithHasMore 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/137/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -146,6 +150,7 @@ exports[`./NetworkLogosList.stories ManyNetworksWithoutHasMore 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -158,6 +163,7 @@ exports[`./NetworkLogosList.stories ManyNetworksWithoutHasMore 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/137/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -216,6 +222,7 @@ exports[`./NetworkLogosList.stories SingleNetwork 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -244,6 +251,7 @@ exports[`./NetworkLogosList.stories TwoNetworks 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/1/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -256,6 +264,7 @@ exports[`./NetworkLogosList.stories TwoNetworks 1`] = `
height="24"
loading="lazy"
src="https://safe-transaction-assets.staging.5afe.dev/chains/137/chain_logo.png"
style="min-width: 24px;"
width="24"
/>
</span>
@@ -1,32 +1,38 @@
import { Typography, Skeleton } from '@mui/material'
import { Skeleton } from '@mui/material'
import FiatValue from '@/components/common/FiatValue'
import { Typography } from '@/components/ui/typography'
import css from '../AccountItems/styles.module.css'
import { cn } from '@/utils/cn'
export interface AccountItemBalanceProps {
fiatTotal?: string | number
isLoading?: boolean
hideBalance?: boolean
'data-testid'?: string
className?: string
}
function AccountItemBalance({ fiatTotal, isLoading, hideBalance, 'data-testid': testId }: AccountItemBalanceProps) {
function AccountItemBalance({
fiatTotal,
isLoading,
hideBalance,
className,
'data-testid': testId,
}: AccountItemBalanceProps) {
if (hideBalance) {
return null
}
return (
<Typography
variant="body2"
className={css.accountItemBalance}
sx={{ fontWeight: 'bold', pl: 1, minWidth: 80 }}
data-testid={testId}
>
<div className={cn(css.accountItemBalance, className)} data-testid={testId}>
{fiatTotal !== undefined ? (
<FiatValue value={fiatTotal} />
<Typography variant="paragraph-small-medium" color="muted">
<FiatValue value={fiatTotal} />
</Typography>
) : isLoading ? (
<Skeleton variant="text" width={60} />
) : null}
</Typography>
</div>
)
}
@@ -1,47 +1,47 @@
import { Box, Tooltip, Typography } from '@mui/material'
import ChainIndicator from '@/components/common/ChainIndicator'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { NetworkLogosList } from '@/features/multichain'
import type { SafeItem } from '@/hooks/safes'
import css from '../AccountItems/styles.module.css'
import { cn } from '@/utils/cn'
export interface AccountItemChainBadgeProps {
/** Single chain mode */
chainId?: string
/** Multi-chain mode - renders network logos with tooltip */
safes?: SafeItem[]
imageSize?: number
className?: string
}
function AccountItemChainBadge({ chainId, safes }: AccountItemChainBadgeProps) {
function AccountItemChainBadge({ chainId, safes, className, imageSize = 24 }: AccountItemChainBadgeProps) {
// Multi-chain mode: render NetworkLogosList with tooltip
if (safes && safes.length > 0) {
return (
<Box className={css.multiChains}>
<Tooltip
title={
<Box data-testid="multichain-tooltip">
<Typography fontSize="14px">Multichain account on:</Typography>
{safes.map((safeItem) => (
<Box key={safeItem.chainId} sx={{ p: '4px 0px' }}>
<ChainIndicator chainId={safeItem.chainId} />
</Box>
))}
</Box>
}
arrow
>
<Box>
<div className={cn('flex shrink-0 justify-end', className)}>
<Tooltip>
<TooltipTrigger render={<span />} className="flex items-center">
<NetworkLogosList networks={safes} showHasMore />
</Box>
</TooltipTrigger>
<TooltipContent>
<div data-testid="multichain-tooltip">
<p className="text-sm">Multichain account on:</p>
{safes.map((safeItem) => (
<div key={safeItem.chainId} className="py-1">
<ChainIndicator imageSize={imageSize} chainId={safeItem.chainId} />
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</Box>
</div>
)
}
// Single chain mode: render ChainIndicator
if (chainId) {
return (
<div className={css.accountItemChainBadge}>
<ChainIndicator chainId={chainId} responsive onlyLogo className={css.chainIndicator} />
<div className="shrink-0">
<ChainIndicator chainId={chainId} responsive onlyLogo className="justify-end" />
</div>
)
}
@@ -98,9 +98,7 @@
}
.accountItemBalance {
flex-shrink: 0;
text-align: right;
align-self: center;
@apply flex-shrink-0 text-right self-center;
}
.accountItemChainBadge {
@@ -0,0 +1,39 @@
import type { ReactElement, ReactNode } from 'react'
import { Typography } from '@/components/ui/typography'
import { AccountItem } from '../AccountItem'
import type { Account } from './types'
import Identicon from '@/components/common/Identicon'
import { shortenAddress } from '@safe-global/utils/utils/formatters'
import { Avatar } from '@/components/ui/avatar'
interface AccountItemContentProps {
account: Account
children: ReactNode
}
const AccountItemContent = ({ account, children }: AccountItemContentProps): ReactElement => {
return (
<>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Avatar>
<Identicon address={account.address} size={40} />
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5 text-left">
<Typography variant="paragraph-bold">{account.name}</Typography>
<Typography variant="paragraph-mini" color="muted">
{shortenAddress(account.address, 4)}
</Typography>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-4">
<div className="flex items-center justify-center">
<AccountItem.ChainBadge safes={account.safes} />
</div>
{children}
</div>
</>
)
}
export { AccountItemContent }
@@ -0,0 +1,54 @@
import type { ReactElement } from 'react'
import { UserRound } from 'lucide-react'
import { Typography } from '@/components/ui/typography'
import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import { WidgetItem } from '@/features/spaces/components/SafeWidget'
import { AccountItem } from '../AccountItem'
import type { Account } from './types'
import Identicon from '@/components/common/Identicon'
import { shortenAddress } from '@safe-global/utils/utils/formatters'
interface AccountWidgetItemProps {
account: Account
loading?: boolean
onItemClick?: (safeAddress: string) => void
}
const AccountWidgetItem = ({ account, loading = false, onItemClick }: AccountWidgetItemProps): ReactElement => {
return (
<WidgetItem
href={account.href}
onClick={onItemClick ? () => onItemClick(account.address) : undefined}
label={<Typography variant="paragraph-bold">{account.name}</Typography>}
info={
<Typography variant="paragraph-mini" color="muted">
{shortenAddress(account.address, 4)}
</Typography>
}
startNode={
<Avatar>
<Identicon address={account.address} size={40} />
</Avatar>
}
featuredNode={<AccountItem.ChainBadge safes={account.safes} />}
actionNode={
<div className="flex flex-col items-end gap-2">
<AccountItem.Balance
className="w-full"
fiatTotal={account.fiatTotal}
isLoading={!account.fiatTotal && loading}
/>
{!account.subAccounts && (
<Badge variant="secondary">
<UserRound className="size-3" />
{account.owners}
</Badge>
)}
</div>
}
/>
)
}
export { AccountWidgetItem }

Some files were not shown because too many files have changed in this diff Show More