mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(shell): surface wallet at a prominent location (#3836)
This commit is contained in:
@@ -19,10 +19,11 @@ vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
|
||||
describe('CollapsedNavRail', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders Home plus every primary nav destination as an icon button', () => {
|
||||
it('renders Home, Wallet, and every primary nav destination as icon buttons', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||
for (const key of [
|
||||
'nav.home',
|
||||
'nav.wallet',
|
||||
'nav.chat',
|
||||
'nav.human',
|
||||
'nav.brain',
|
||||
@@ -33,6 +34,26 @@ describe('CollapsedNavRail', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('wallet button navigates to /settings/wallet-balances', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances');
|
||||
});
|
||||
|
||||
it('wallet button has correct data-analytics-id', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
|
||||
'data-analytics-id',
|
||||
'collapsed-rail-wallet'
|
||||
);
|
||||
});
|
||||
|
||||
it('wallet button is marked active when on /settings/wallet-balances', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/settings/wallet-balances'] });
|
||||
const btn = screen.getByRole('button', { name: 'nav.wallet' });
|
||||
expect(btn.className).toMatch(/bg-white|dark:bg-neutral-800/);
|
||||
});
|
||||
|
||||
it('navigates to a destination path when its icon is clicked', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nav.brain' }));
|
||||
|
||||
@@ -67,6 +67,24 @@ export default function CollapsedNavRail() {
|
||||
<NavIcon id="home" className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Wallet shortcut — mirrors SidebarHeader wallet button for collapsed state. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/wallet-balances')}
|
||||
title={t('nav.wallet')}
|
||||
aria-label={t('nav.wallet')}
|
||||
aria-current={
|
||||
matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
|
||||
}
|
||||
data-analytics-id="collapsed-rail-wallet"
|
||||
className={`${RAIL_BTN} ${
|
||||
matchActive('/settings/wallet-balances', location.pathname)
|
||||
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
|
||||
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
|
||||
}`}>
|
||||
<NavIcon id="wallet" className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Primary nav destinations */}
|
||||
{tabs.map(tab => {
|
||||
const active = matchActive(tab.path, location.pathname);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const mockHome = vi.fn();
|
||||
const mockHide = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
vi.mock('./useHomeNav', () => ({ useHomeNav: () => mockHome }));
|
||||
vi.mock('./RootShellLayout', () => ({ useRootSidebar: () => ({ hide: mockHide }) }));
|
||||
// Return i18n keys verbatim so queries don't depend on locale.
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
describe('SidebarHeader', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders Home, Wallet, Settings, and Collapse buttons', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
expect(screen.getByRole('button', { name: 'nav.home' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'nav.wallet' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'nav.settings' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'chat.hideSidebar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wallet button navigates to /settings/wallet-balances', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances');
|
||||
});
|
||||
|
||||
it('wallet button has correct data-analytics-id', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
|
||||
'data-analytics-id',
|
||||
'sidebar-header-wallet'
|
||||
);
|
||||
});
|
||||
|
||||
it('wallet button has matching aria-label and title', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
const btn = screen.getByRole('button', { name: 'nav.wallet' });
|
||||
expect(btn).toHaveAttribute('aria-label', 'nav.wallet');
|
||||
expect(btn).toHaveAttribute('title', 'nav.wallet');
|
||||
});
|
||||
|
||||
it('settings button navigates to /settings', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nav.settings' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('Home button invokes the shared Home action', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nav.home' }));
|
||||
expect(mockHome).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Collapse button calls hide()', () => {
|
||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'chat.hideSidebar' }));
|
||||
expect(mockHide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,24 @@ export default function SidebarHeader() {
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Wallet shortcut — one-click access to wallet balances. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/wallet-balances')}
|
||||
className={ICON_BTN}
|
||||
data-analytics-id="sidebar-header-wallet"
|
||||
aria-label={t('nav.wallet')}
|
||||
title={t('nav.wallet')}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings')}
|
||||
|
||||
@@ -97,6 +97,17 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'wallet':
|
||||
return (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'brain':
|
||||
return (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'النشاط',
|
||||
'nav.brain': 'الدماغ',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'المحفظة',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place شبكة اجتماعية لوكلاء الذكاء الاصطناعي. استخدم OpenHuman للتفاعل والعثور على الوظائف ونشرها والتداول والنمو معًا.',
|
||||
'agentWorld.feed': 'التغذية',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'কার্যকলাপ',
|
||||
'nav.brain': 'ব্রেইন',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'ওয়ালেট',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place হলো এআই এজেন্টদের জন্য একটি সোশ্যাল নেটওয়ার্ক। যোগাযোগ করতে, কাজ খুঁজতে ও পোস্ট করতে, লেনদেন করতে এবং একসাথে বেড়ে উঠতে OpenHuman ব্যবহার করুন।',
|
||||
'agentWorld.feed': 'ফিড',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Aktivität',
|
||||
'nav.brain': 'Gehirn',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Wallet',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place ist ein soziales Netzwerk für KI-Agenten. Nutze OpenHuman, um zu interagieren, Jobs zu finden und zu veröffentlichen, zu handeln und gemeinsam zu wachsen.',
|
||||
'agentWorld.feed': 'Feed',
|
||||
|
||||
@@ -24,6 +24,7 @@ const en: TranslationMap = {
|
||||
'nav.activity': 'Activity',
|
||||
'nav.brain': 'Brain',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Wallet',
|
||||
// Agent World section sub-navigation labels
|
||||
'agentWorld.description':
|
||||
'Tiny.Place is a social network for AI agents. Use OpenHuman to interact, find and post jobs, trade, and grow together.',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Actividad',
|
||||
'nav.brain': 'Cerebro',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Cartera',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place es una red social para agentes de IA. Usa OpenHuman para interactuar, encontrar y publicar trabajos, comerciar y crecer juntos.',
|
||||
'agentWorld.feed': 'Noticias',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Activité',
|
||||
'nav.brain': 'Cerveau',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Portefeuille',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place est un réseau social pour les agents IA. Utilisez OpenHuman pour interagir, trouver et publier des missions, échanger et grandir ensemble.',
|
||||
'agentWorld.feed': 'Fil',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'गतिविधि',
|
||||
'nav.brain': 'ब्रेन',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'वॉलेट',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place एआई एजेंट्स के लिए एक सोशल नेटवर्क है। बातचीत करने, काम खोजने और पोस्ट करने, व्यापार करने और साथ मिलकर आगे बढ़ने के लिए OpenHuman का उपयोग करें।',
|
||||
'agentWorld.feed': 'फ़ीड',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Aktivitas',
|
||||
'nav.brain': 'Otak',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Dompet',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place adalah jejaring sosial untuk agen AI. Gunakan OpenHuman untuk berinteraksi, menemukan dan memasang pekerjaan, berdagang, serta tumbuh bersama.',
|
||||
'agentWorld.feed': 'Feed',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Attività',
|
||||
'nav.brain': 'Cervello',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Portafoglio',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place è un social network per agenti IA. Usa OpenHuman per interagire, trovare e pubblicare lavori, scambiare e crescere insieme.',
|
||||
'agentWorld.feed': 'Feed',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': '활동',
|
||||
'nav.brain': '브레인',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': '지갑',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place는 AI 에이전트를 위한 소셜 네트워크입니다. OpenHuman을 사용해 소통하고, 일자리를 찾고 올리며, 거래하고 함께 성장하세요.',
|
||||
'agentWorld.feed': '피드',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Aktywność',
|
||||
'nav.brain': 'Mózg',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Portfel',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place to sieć społecznościowa dla agentów AI. Używaj OpenHuman, aby wchodzić w interakcje, znajdować i publikować zlecenia, handlować i wspólnie się rozwijać.',
|
||||
'agentWorld.feed': 'Kanał',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Atividade',
|
||||
'nav.brain': 'Cérebro',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Carteira',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place é uma rede social para agentes de IA. Use o OpenHuman para interagir, encontrar e publicar trabalhos, negociar e crescer juntos.',
|
||||
'agentWorld.feed': 'Feed',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Активность',
|
||||
'nav.brain': 'Мозг',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': 'Кошелёк',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place — это социальная сеть для ИИ-агентов. Используйте OpenHuman, чтобы взаимодействовать, находить и публиковать задания, торговать и расти вместе.',
|
||||
'agentWorld.feed': 'Лента',
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
|
||||
'nav.activity': '动态',
|
||||
'nav.brain': '大脑',
|
||||
'nav.agentWorld': 'Tiny.Place',
|
||||
'nav.wallet': '钱包',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place 是面向 AI 智能体的社交网络。使用 OpenHuman 来互动、查找和发布工作、交易并共同成长。',
|
||||
'agentWorld.feed': '动态',
|
||||
|
||||
Reference in New Issue
Block a user