diff --git a/src/components/common/time-display.tsx b/src/components/common/time-display.tsx index 90f5565ac..0eeed6cf2 100644 --- a/src/components/common/time-display.tsx +++ b/src/components/common/time-display.tsx @@ -56,6 +56,7 @@ export function TimeDisplay({ value, format = 'relative', className }: TimeDispl day: '2-digit', hour: '2-digit', minute: '2-digit', + timeZoneName: 'short', // 显示时区信息以避免歧义 }); break; case 'time': @@ -72,6 +73,7 @@ export function TimeDisplay({ value, format = 'relative', className }: TimeDispl day: '2-digit', hour: '2-digit', minute: '2-digit', + timeZoneName: 'short', }); return ( diff --git a/src/components/contact/contact-card.tsx b/src/components/contact/contact-card.tsx index 8268a5562..3d4ef2f0f 100644 --- a/src/components/contact/contact-card.tsx +++ b/src/components/contact/contact-card.tsx @@ -8,19 +8,48 @@ import { ContactAvatar } from '@/components/common/contact-avatar'; import { generateAvatarFromAddress } from '@/lib/avatar-codec'; import { detectAddressFormat } from '@/lib/address-format'; import type { ContactAddressInfo } from '@/lib/qr-parser'; +import { isBioforestChain } from '@/lib/crypto'; -const CHAIN_COLORS: Record = { - ethereum: '#627EEA', - bitcoin: '#F7931A', - tron: '#FF0013', +/** Address format standard colors */ +const ADDRESS_FORMAT_COLORS = { + evm: '#627EEA', // EVM (0x...) - ethereum, binance + bitcoin: '#F7931A', // Bitcoin (1.../3.../bc1...) + tron: '#FF0013', // TRON (T...) + bioforest: '#6366F1', // BioForest (all BioForest chains share same format) }; +/** Get color based on address format standard */ +function getAddressFormatColor(chainType: string | null): string { + if (!chainType) return '#6B7280'; + + // EVM-compatible chains + if (chainType === 'ethereum' || chainType === 'binance') { + return ADDRESS_FORMAT_COLORS.evm; + } + + // Bitcoin + if (chainType === 'bitcoin') { + return ADDRESS_FORMAT_COLORS.bitcoin; + } + + // TRON + if (chainType === 'tron') { + return ADDRESS_FORMAT_COLORS.tron; + } + + // BioForest chains (all use same address format) + if (isBioforestChain(chainType)) { + return ADDRESS_FORMAT_COLORS.bioforest; + } + + return '#6B7280'; +} + /** 获取地址显示标签和颜色(只显示自定义 label) */ function getAddressDisplay(addr: ContactAddressInfo): { label: string; color: string } | null { if (!addr.label) return null; const detected = detectAddressFormat(addr.address); - const chainType = detected.chainType; - const color = chainType ? CHAIN_COLORS[chainType] || '#6B7280' : '#6B7280'; + const color = getAddressFormatColor(detected.chainType); return { label: addr.label, color }; } diff --git a/src/hooks/useSnapdomShare.ts b/src/hooks/useSnapdomShare.ts new file mode 100644 index 000000000..3cb0009e7 --- /dev/null +++ b/src/hooks/useSnapdomShare.ts @@ -0,0 +1,90 @@ +/** + * useSnapdomShare - 截图分享 hook + * + * 封装 snapdom 的下载和分享逻辑,供 ContactCard 相关页面复用 + */ + +import { useCallback, useState, type RefObject } from 'react'; + +export interface UseSnapdomShareOptions { + /** 下载/分享的文件名(不含扩展名) */ + filename: string; + /** 分享时的标题 */ + shareTitle?: string; + /** 分享时的描述文本 */ + shareText?: string; +} + +export interface UseSnapdomShareResult { + /** 是否正在处理(下载或分享中) */ + isProcessing: boolean; + /** 下载为 PNG 图片 */ + download: () => Promise; + /** 使用系统分享功能 */ + share: () => Promise; + /** 浏览器是否支持分享 */ + canShare: boolean; +} + +/** + * 截图分享 hook + * @param cardRef 需要截图的元素引用 + * @param options 配置选项 + */ +export function useSnapdomShare( + cardRef: RefObject, + options: UseSnapdomShareOptions +): UseSnapdomShareResult { + const [isProcessing, setIsProcessing] = useState(false); + const { filename, shareTitle, shareText } = options; + + const download = useCallback(async () => { + const element = cardRef.current; + if (!element || isProcessing) return; + + setIsProcessing(true); + try { + const { snapdom } = await import('@zumer/snapdom'); + await snapdom.download(element, { + type: 'png', + filename: `${filename}.png`, + scale: 2, + quality: 1, + }); + } catch (error) { + console.error('Download failed:', error); + } finally { + setIsProcessing(false); + } + }, [cardRef, filename, isProcessing]); + + const share = useCallback(async () => { + const element = cardRef.current; + if (!element || !navigator.share || isProcessing) return; + + setIsProcessing(true); + try { + const { snapdom } = await import('@zumer/snapdom'); + const result = await snapdom(element, { scale: 2 }); + const blob = await result.toBlob(); + const file = new File([blob], `${filename}.png`, { type: 'image/png' }); + + await navigator.share({ + title: shareTitle, + text: shareText, + files: [file], + }); + } catch { + // User cancelled or share failed + } finally { + setIsProcessing(false); + } + }, [cardRef, filename, shareTitle, shareText, isProcessing]); + + return { + isProcessing, + download, + share, + canShare: typeof navigator !== 'undefined' && 'share' in navigator, + }; +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index b07b742c9..84fe19292 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -344,6 +344,20 @@ "scanToAdd": "Scan to add contact", "changeAvatar": "Click to change avatar" }, + "myCard": { + "title": "My Business Card", + "defaultName": "My Card", + "editUsername": "Tap to edit username", + "usernamePlaceholder": "Enter username", + "changeAvatar": "Tap to change avatar", + "selectWallets": "Select Wallets", + "addWallet": "Add Wallet", + "maxWallets": "Select up to {{max}} wallets", + "noWalletsSelected": "Please select at least one wallet", + "scanToAdd": "Scan to add me as contact", + "walletAddress": "{{wallet}} ({{chain}})", + "currentChain": "Current chain" + }, "time": { "justNow": "Just now", "minutesAgo": "{{count}} min ago", @@ -373,4 +387,4 @@ "openExplorer": "Open {{name}} Explorer", "viewOnExplorer": "View on {{name}}" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 0bfca825e..814ad55ce 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -9,6 +9,7 @@ "about": "About" }, "items": { + "myCard": "My Business Card", "walletManagement": "Wallet Management", "walletChains": "Wallet Networks", "addressBook": "Address Book", @@ -146,4 +147,4 @@ "migrationDesc": "Detected old data format. Please clear the local database to continue. Your mnemonic and private keys are not affected, but you will need to re-import your wallet.", "goToClear": "Go to Clear Data" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json index 226ddfd02..06366f256 100644 --- a/src/i18n/locales/en/transaction.json +++ b/src/i18n/locales/en/transaction.json @@ -19,7 +19,9 @@ "share": "Share", "shareTitle": "BFM Pay Receive Address", "addressCopied": "Address copied", - "networkWarning": "Only {{chain}} network assets can be transferred in, assets from other networks cannot be recovered" + "networkWarning": "Only {{chain}} network assets can be transferred in, assets from other networks cannot be recovered", + "saveImage": "Save Image", + "imageSaved": "Image saved" }, "sendResult": { "success": "Transfer Successful", @@ -339,4 +341,4 @@ "viewAll": "View all {{count}} pending transactions", "clearAllFailed": "Clear failed" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 2a3a68ee0..71c83be3d 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -322,6 +322,20 @@ "scanToAdd": "扫码添加联系人", "changeAvatar": "点击切换头像" }, + "myCard": { + "title": "我的名片", + "defaultName": "我的名片", + "editUsername": "点击编辑用户名", + "usernamePlaceholder": "输入用户名", + "changeAvatar": "点击换头像", + "selectWallets": "选择钱包", + "addWallet": "添加钱包", + "maxWallets": "最多选择 {{max}} 个钱包", + "noWalletsSelected": "请选择至少一个钱包", + "scanToAdd": "扫码添加我为联系人", + "walletAddress": "{{wallet}} ({{chain}})", + "currentChain": "当前链" + }, "time": { "justNow": "刚刚", "minutesAgo": "{{count}} 分钟前", @@ -351,4 +365,4 @@ "openExplorer": "打开 {{name}} 浏览器", "viewOnExplorer": "在 {{name}} 浏览器中查看" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 9f7fac00a..9696352be 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -9,6 +9,7 @@ "about": "关于" }, "items": { + "myCard": "我的名片", "walletManagement": "钱包管理", "walletChains": "钱包网络", "addressBook": "地址簿", @@ -146,4 +147,4 @@ "migrationDesc": "检测到旧版本数据格式,需要清空本地数据库后才能继续使用。您的助记词和私钥不会受到影响,但需要重新导入钱包。", "goToClear": "前往清理数据" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json index b3c8b5a22..9c1f9d1bd 100644 --- a/src/i18n/locales/zh-CN/transaction.json +++ b/src/i18n/locales/zh-CN/transaction.json @@ -19,7 +19,9 @@ "share": "分享", "shareTitle": "BFM Pay 收款地址", "addressCopied": "地址已复制", - "networkWarning": "仅支持 {{chain}} 网络资产转入,其他网络资产转入将无法找回" + "networkWarning": "仅支持 {{chain}} 网络资产转入,其他网络资产转入将无法找回", + "saveImage": "保存图片", + "imageSaved": "图片已保存" }, "sendResult": { "success": "转账成功", @@ -339,4 +341,4 @@ "viewAll": "查看全部 {{count}} 条待处理交易", "clearAllFailed": "清除失败" } -} +} \ No newline at end of file diff --git a/src/pages/my-card/index.stories.tsx b/src/pages/my-card/index.stories.tsx new file mode 100644 index 000000000..648f65f9e --- /dev/null +++ b/src/pages/my-card/index.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MyCardPage } from './index'; + +/** + * MyCardPage - 我的名片页面 + * + * 功能: + * - 显示和编辑用户名 + * - 点击头像随机更换 + * - 选择最多3个钱包显示在名片上 + * - QR码使用 contact 协议 + * - 下载/分享功能 + */ +const meta: Meta = { + title: 'Pages/MyCardPage', + component: MyCardPage, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: '我的名片页面 - 用于分享个人钱包地址二维码', + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithUsername: Story = { + play: async () => { + // Pre-set username for testing + const { userProfileActions } = await import('@/stores'); + userProfileActions.setUsername('测试用户'); + userProfileActions.randomizeAvatar(); + }, +}; diff --git a/src/pages/my-card/index.test.tsx b/src/pages/my-card/index.test.tsx new file mode 100644 index 000000000..6bcfa51a3 --- /dev/null +++ b/src/pages/my-card/index.test.tsx @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MyCardPage } from './index'; + +// Mock i18next - inline +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + 'myCard.title': '我的名片', + 'myCard.defaultName': '我的名片', + 'myCard.changeAvatar': '点击换头像', + 'myCard.usernamePlaceholder': '输入用户名', + 'myCard.selectWallets': '选择钱包', + 'myCard.addWallet': '添加钱包', + 'myCard.maxWallets': `最多选择 ${params?.max ?? 3} 个钱包`, + 'myCard.noWalletsSelected': '请选择至少一个钱包', + 'myCard.scanToAdd': '扫码添加我为联系人', + 'download': '下载', + 'share': '分享', + }; + return translations[key] ?? key; + }, + }), +})); + +// Mock stackflow - inline +vi.mock('@/stackflow', () => ({ + useNavigation: () => ({ + goBack: vi.fn(), + }), +})); + +// Mock stores - all inline with vi.fn inside factory +vi.mock('@/stores', () => ({ + useUserProfile: () => ({ + username: '', + avatar: 'avatar:TESTDATA', + selectedWalletIds: ['wallet-1'], + }), + userProfileActions: { + setUsername: vi.fn(), + randomizeAvatar: vi.fn(), + initializeDefaultAvatar: vi.fn(), + toggleWalletSelection: vi.fn(), + }, + useWallets: () => [ + { + id: 'wallet-1', + name: 'Main Wallet', + chain: 'ethereum', + themeHue: 220, // Blue theme + chainAddresses: [ + { chain: 'ethereum', address: '0x1234567890abcdef1234567890abcdef12345678' }, + { chain: 'bitcoin', address: 'bc1qtest123' }, + ], + }, + { + id: 'wallet-2', + name: 'Savings', + chain: 'bitcoin', + themeHue: 30, // Orange theme + chainAddresses: [ + { chain: 'bitcoin', address: 'bc1qsavings456' }, + ], + }, + ], + useChainPreferences: () => ({ + 'wallet-1': 'ethereum', + 'wallet-2': 'bitcoin', + }), + useSelectedWalletIds: () => ['wallet-1'], + useCanAddMoreWallets: () => true, + useIsWalletSelected: () => false, +})); + +// Mock QR parser - inline +vi.mock('@/lib/qr-parser', () => ({ + generateContactQRContent: vi.fn(() => '{"type":"contact","name":"test"}'), +})); + +// Mock ContactCard - inline +vi.mock('@/components/contact/contact-card', () => ({ + ContactCard: ({ name, avatar, addresses, qrContent }: { name: string; avatar: string; addresses: unknown[]; qrContent: string }) => ( +
+ {name} + {avatar} + {JSON.stringify(addresses)} + {qrContent} +
+ ), +})); + +// Mock ContactAvatar - inline +vi.mock('@/components/common/contact-avatar', () => ({ + ContactAvatar: ({ src, size }: { src: string; size: number }) => ( +
+ ), +})); + +// Mock refraction module for wallet theme colors +vi.mock('@/components/wallet/refraction', () => ({ + resolveBackgroundStops: (themeHue: number) => ({ + themeHue, + c0: `rgb(66, 115, ${themeHue})`, // Predictable mock value + c1: 'rgb(60, 100, 180)', + c2: 'rgb(50, 80, 150)', + }), +})); + +// Mock snapdom - inline +vi.mock('@zumer/snapdom', () => ({ + snapdom: { + download: vi.fn(), + }, +})); + +// Mock Sheet component - inline +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + SheetContent: ({ children }: { children: React.ReactNode }) => +
{children}
, + SheetHeader: ({ children }: { children: React.ReactNode }) => +
{children}
, + SheetTitle: ({ children }: { children: React.ReactNode }) => +

{children}

, +})); + +describe('MyCardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('AC-1: Page Rendering', () => { + it('renders page header with title', () => { + render(); + // Use testid since '我的名片' appears in multiple places + expect(screen.getByTestId('page-title')).toHaveTextContent('我的名片'); + }); + + it('renders avatar area', () => { + render(); + expect(screen.getByTestId('contact-avatar')).toBeInTheDocument(); + }); + + it('renders ContactCard with QR code', () => { + render(); + expect(screen.getByTestId('contact-card')).toBeInTheDocument(); + }); + + it('renders download button', () => { + render(); + expect(screen.getByText('下载')).toBeInTheDocument(); + }); + }); + + describe('AC-2: Avatar Functionality', () => { + it('clicking avatar triggers randomizeAvatar', async () => { + const { userProfileActions } = await import('@/stores'); + render(); + + const avatarButton = screen.getByLabelText('点击换头像'); + await userEvent.click(avatarButton); + + expect(userProfileActions.randomizeAvatar).toHaveBeenCalled(); + }); + }); + + describe('AC-4: Wallet Selection', () => { + it('displays selected wallet chip', () => { + render(); + expect(screen.getByText('Main Wallet')).toBeInTheDocument(); + }); + + it('shows add wallet button when under limit', () => { + render(); + expect(screen.getByText('添加钱包')).toBeInTheDocument(); + }); + + it('wallet chips use themeHue-based colors from resolveBackgroundStops', () => { + render(); + // Wallet chip uses mocked resolveBackgroundStops returning rgb(66, 115, themeHue) + const walletChip = screen.getByText('Main Wallet').closest('div'); + // mock themeHue 220 -> c0 = rgb(66, 115, 220) + expect(walletChip).toHaveStyle({ backgroundColor: 'rgb(66, 115, 220)' }); + }); + + it('shows max wallets message', () => { + render(); + expect(screen.getByText(/最多选择.*3.*个钱包/)).toBeInTheDocument(); + }); + }); + + describe('AC-5: QR Code Generation', () => { + it('calls generateContactQRContent', async () => { + const { generateContactQRContent } = await import('@/lib/qr-parser'); + render(); + + expect(generateContactQRContent).toHaveBeenCalled(); + }); + + it('displays addresses from selected wallets in ContactCard', () => { + render(); + + const addressesElement = screen.getByTestId('card-addresses'); + expect(addressesElement.textContent).toContain('0x1234567890abcdef'); + }); + }); + + describe('AC-6: Download/Share', () => { + it('download button is enabled when wallets are selected', () => { + render(); + + const downloadButton = screen.getByRole('button', { name: /下载/i }); + expect(downloadButton).not.toBeDisabled(); + }); + + it('clicking download triggers snapdom.download', async () => { + const { snapdom } = await import('@zumer/snapdom'); + render(); + + const downloadButton = screen.getByRole('button', { name: /下载/i }); + await userEvent.click(downloadButton); + + // Wait for async download to complete + await waitFor(() => { + expect(snapdom.download).toHaveBeenCalled(); + }, { timeout: 3000 }); + }); + }); +}); diff --git a/src/pages/my-card/index.tsx b/src/pages/my-card/index.tsx new file mode 100644 index 000000000..b6681bba8 --- /dev/null +++ b/src/pages/my-card/index.tsx @@ -0,0 +1,309 @@ +/** + * MyCardPage - 我的名片页面 + * + * 功能: + * - 编辑用户名 + * - 点击头像随机生成新头像 + * - 选择最多 3 个钱包显示在名片上 + * - 使用 ContactCard 显示二维码 + * - snapdom 下载/分享 + */ + +import { useRef, useCallback, useState, useMemo, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigation } from '@/stackflow'; +import { PageHeader } from '@/components/layout/page-header'; +import { ContactCard } from '@/components/contact/contact-card'; +import { ContactAvatar } from '@/components/common/contact-avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { generateContactQRContent, type ContactAddressInfo } from '@/lib/qr-parser'; +import { + useUserProfile, + userProfileActions, + useWallets, + useChainPreferences, + type Wallet, + type ChainType, +} from '@/stores'; +import { + IconDownload as Download, + IconShare as Share, + IconLoader2 as Loader, + IconPlus as Plus, + IconX as X, + IconPencil as Pencil, +} from '@tabler/icons-react'; +import { WalletPickerSheet } from './wallet-picker-sheet'; +import { resolveBackgroundStops } from '@/components/wallet/refraction'; +import { useSnapdomShare } from '@/hooks/useSnapdomShare'; + +const CHAIN_NAMES: Record = { + ethereum: 'ETH', + bitcoin: 'BTC', + tron: 'TRX', + binance: 'BSC', + bfmeta: 'BFMeta', + ccchain: 'CCChain', + pmchain: 'PMChain', + bfchainv2: 'BFChain V2', + btgmeta: 'BTGMeta', + biwmeta: 'BIWMeta', + ethmeta: 'ETHMeta', + malibu: 'Malibu', +}; + + + +export function MyCardPage() { + const { t } = useTranslation(['common', 'settings']); + const { goBack } = useNavigation(); + const cardRef = useRef(null); + + const profile = useUserProfile(); + const wallets = useWallets(); + const chainPreferences = useChainPreferences(); + + const [isEditingUsername, setIsEditingUsername] = useState(false); + const [usernameInput, setUsernameInput] = useState(profile.username); + const [showWalletPicker, setShowWalletPicker] = useState(false); + + // Initialize default avatar if not set + useEffect(() => { + if (!profile.avatar && wallets.length > 0) { + const firstAddress = wallets[0]?.chainAddresses[0]?.address; + if (firstAddress) { + userProfileActions.initializeDefaultAvatar(firstAddress); + } + } + }, [profile.avatar, wallets]); + + // Auto-select first wallet if none selected + useEffect(() => { + if (profile.selectedWalletIds.length === 0 && wallets.length > 0) { + const firstWallet = wallets[0]; + if (firstWallet) { + userProfileActions.toggleWalletSelection(firstWallet.id); + } + } + }, [profile.selectedWalletIds, wallets]); + + // Get selected wallets with their current chain addresses + const selectedWalletsWithAddresses = useMemo(() => { + return profile.selectedWalletIds + .map(id => wallets.find(w => w.id === id)) + .filter((w): w is Wallet => !!w) + .map(wallet => { + const selectedChain = chainPreferences[wallet.id] || wallet.chain; + const chainAddress = wallet.chainAddresses.find(ca => ca.chain === selectedChain); + return { + wallet, + chain: selectedChain, + address: chainAddress?.address, + }; + }) + .filter(item => item.address); + }, [profile.selectedWalletIds, wallets, chainPreferences]); + + // Generate addresses for QR code + const addresses: ContactAddressInfo[] = useMemo(() => { + return selectedWalletsWithAddresses.map(({ wallet, address }) => ({ + address: address!, + label: wallet.name, // Only wallet name, color indicates address type + })); + }, [selectedWalletsWithAddresses]); + + // Generate QR content + const qrContent = useMemo(() => { + if (addresses.length === 0) return ''; + return generateContactQRContent({ + name: profile.username || t('myCard.defaultName'), + addresses, + avatar: profile.avatar, + }); + }, [profile.username, profile.avatar, addresses, t]); + + // Handle avatar click - randomize + const handleAvatarClick = useCallback(() => { + userProfileActions.randomizeAvatar(); + }, []); + + // Handle username editing + const handleUsernameClick = useCallback(() => { + setUsernameInput(profile.username); + setIsEditingUsername(true); + }, [profile.username]); + + const handleUsernameSave = useCallback(() => { + userProfileActions.setUsername(usernameInput); + setIsEditingUsername(false); + }, [usernameInput]); + + const handleUsernameKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleUsernameSave(); + } else if (e.key === 'Escape') { + setIsEditingUsername(false); + } + }, [handleUsernameSave]); + + // Snapdom share hook + const { isProcessing: isDownloading, download: handleDownload, share: handleShare, canShare } = useSnapdomShare( + cardRef, + { + filename: `my-card-${Date.now()}`, + shareTitle: t('myCard.title'), + shareText: profile.username || t('myCard.defaultName'), + } + ); + + const displayName = profile.username || t('myCard.defaultName'); + + return ( +
+ + +
+ {/* Avatar - clickable to randomize */} + + + {/* Username - clickable to edit */} + {isEditingUsername ? ( +
+ setUsernameInput(e.target.value)} + onBlur={handleUsernameSave} + onKeyDown={handleUsernameKeyDown} + placeholder={t('myCard.usernamePlaceholder')} + className="w-48 text-center" + autoFocus + /> +
+ ) : ( + + )} + + {/* Business Card Preview */} + {addresses.length > 0 ? ( +
+ +
+ ) : ( +
+

{t('myCard.noWalletsSelected')}

+
+ )} + + {/* Selected Wallets */} +
+

+ {t('myCard.selectWallets')} +

+
+ {selectedWalletsWithAddresses.map(({ wallet, chain }) => { + const { c0 } = resolveBackgroundStops(wallet.themeHue); + return ( +
+ {wallet.name} + ({CHAIN_NAMES[chain] || chain}) + +
+ ); + })} + {profile.selectedWalletIds.length < 3 && ( + + )} +
+

+ {t('myCard.maxWallets', { max: 3 })} +

+
+ + {/* Action Buttons */} +
+ + {'share' in navigator && ( + + )} +
+ + {/* Instruction */} +

+ {t('myCard.scanToAdd')} +

+
+ + {/* Wallet Picker Sheet */} + +
+ ); +} + +export default MyCardPage; diff --git a/src/pages/my-card/wallet-picker-sheet.tsx b/src/pages/my-card/wallet-picker-sheet.tsx new file mode 100644 index 000000000..c6c3938a2 --- /dev/null +++ b/src/pages/my-card/wallet-picker-sheet.tsx @@ -0,0 +1,119 @@ +/** + * WalletPickerSheet - 钱包选择器底部弹窗 + * + * 用于在我的名片中选择要显示的钱包(最多3个) + */ + +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ChainIcon } from '@/components/wallet/chain-icon'; +import { IconCheck as Check } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import { + useWallets, + useChainPreferences, + useSelectedWalletIds, + useCanAddMoreWallets, + userProfileActions, + type ChainType, +} from '@/stores'; + +const CHAIN_NAMES: Record = { + ethereum: 'Ethereum', + bitcoin: 'Bitcoin', + tron: 'Tron', + binance: 'BSC', + bfmeta: 'BFMeta', + ccchain: 'CCChain', + pmchain: 'PMChain', + bfchainv2: 'BFChain V2', + btgmeta: 'BTGMeta', + biwmeta: 'BIWMeta', + ethmeta: 'ETHMeta', + malibu: 'Malibu', +}; + +interface WalletPickerSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function WalletPickerSheet({ open, onOpenChange }: WalletPickerSheetProps) { + const { t } = useTranslation('common'); + const wallets = useWallets(); + const chainPreferences = useChainPreferences(); + const selectedWalletIds = useSelectedWalletIds(); + const canAddMore = useCanAddMoreWallets(); + + const handleWalletToggle = useCallback((walletId: string) => { + const isSelected = selectedWalletIds.includes(walletId); + + // If not selected and can't add more, don't do anything + if (!isSelected && !canAddMore) return; + + userProfileActions.toggleWalletSelection(walletId); + }, [selectedWalletIds, canAddMore]); + + return ( + + + + {t('myCard.selectWallets')} + + +
+ {wallets.map((wallet) => { + const selectedChain = chainPreferences[wallet.id] || wallet.chain; + const chainAddress = wallet.chainAddresses.find(ca => ca.chain === selectedChain); + const isSelected = selectedWalletIds.includes(wallet.id); + const isDisabled = !isSelected && !canAddMore; + + return ( + + ); + })} + + {wallets.length === 0 && ( +
+ {t('noAssets')} +
+ )} +
+ +

+ {t('myCard.maxWallets', { max: 3 })} +

+
+
+ ); +} diff --git a/src/pages/receive/index.stories.tsx b/src/pages/receive/index.stories.tsx index 69d8afac8..197b7ae51 100644 --- a/src/pages/receive/index.stories.tsx +++ b/src/pages/receive/index.stories.tsx @@ -1,8 +1,10 @@ -import type { Meta, StoryObj } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' import { ReceivePage } from './index' // Note: This story requires mocking router, stores, and services -// For now we show the structure - full mocking would need decorator setup +// Storybook preview.tsx provides QueryClient and i18n +// Additional mocks are provided via decorators const meta = { title: 'Pages/ReceivePage', @@ -22,13 +24,39 @@ const meta = { export default meta type Story = StoryObj -// Default story - requires proper mocking in .storybook/preview -export const Default: Story = {} +// Default story with play function for visual testing +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Verify page title is rendered + await expect(canvas.getByText('收款')).toBeInTheDocument() + + // Verify QR code renders as actual SVG (not placeholder text) + const svg = canvasElement.querySelector('svg') + await expect(svg).toBeInTheDocument() + + // Verify placeholder text "QR:" is NOT present (this was the bug) + const placeholderText = canvas.queryByText(/^QR:/) + await expect(placeholderText).not.toBeInTheDocument() + + // Verify action buttons are rendered + await expect(canvas.getByText('复制地址')).toBeInTheDocument() + await expect(canvas.getByText('保存图片')).toBeInTheDocument() + await expect(canvas.getByText('分享')).toBeInTheDocument() + + // Verify chain info is displayed + await expect(canvas.getByText('Ethereum')).toBeInTheDocument() + + // Verify instruction text + await expect(canvas.getByText('扫描二维码向此地址转账')).toBeInTheDocument() + }, +} // Note: Additional stories for different chains would require // mocking useSelectedChain with different values: -// - Ethereum // - Bitcoin // - Tron // - BSC // - BFMeta + diff --git a/src/pages/receive/index.test.tsx b/src/pages/receive/index.test.tsx index 271423441..42e8e2698 100644 --- a/src/pages/receive/index.test.tsx +++ b/src/pages/receive/index.test.tsx @@ -26,6 +26,38 @@ const mockAddress = '0x1234567890abcdef1234567890abcdef12345678' vi.mock('@/stores', () => ({ useCurrentChainAddress: () => ({ address: mockAddress }), useSelectedChain: () => 'ethereum', + useUserProfile: () => ({ + username: 'Test User', + avatar: 'avatar:TESTDATA', + selectedWalletIds: [], + }), +})) + +// Mock ContactCard and QR parser +vi.mock('@/components/contact/contact-card', () => ({ + ContactCard: ({ name, addresses }: { name: string; addresses: unknown[] }) => ( +
+ {name} + {JSON.stringify(addresses)} + +
+ ), +})) + +vi.mock('@/lib/qr-parser', () => ({ + generateContactQRContent: vi.fn(() => '{"type":"contact"}'), +})) + +// Mock snapdom for JSDOM environment +vi.mock('@zumer/snapdom', () => ({ + snapdom: Object.assign( + vi.fn().mockResolvedValue({ + toBlob: vi.fn().mockResolvedValue(new Blob(['test'], { type: 'image/png' })), + }), + { + download: vi.fn().mockResolvedValue(undefined), + } + ), })) // Wrapper with providers @@ -49,9 +81,11 @@ describe('ReceivePage', () => { expect(screen.getByText('Ethereum')).toBeInTheDocument() }) - it('shows QR code instruction text', () => { + it('displays ContactCard with user profile', () => { renderWithProviders() - expect(screen.getByText('扫描二维码向此地址转账')).toBeInTheDocument() + // ContactCard should be rendered + expect(screen.getByTestId('contact-card')).toBeInTheDocument() + expect(screen.getByTestId('card-name')).toHaveTextContent('Test User') }) it('displays address label', () => { @@ -68,8 +102,8 @@ describe('ReceivePage', () => { describe('Address Display', () => { it('shows the wallet address', () => { renderWithProviders() - // Address may be truncated in display - expect(screen.getByText(/0x1234/)).toBeInTheDocument() + // Address is displayed in AddressDisplay component + expect(screen.getByText(mockAddress)).toBeInTheDocument() }) }) @@ -104,7 +138,7 @@ describe('ReceivePage', () => { expect(screen.getByText('分享')).toBeInTheDocument() }) - it('calls navigator.share when available', async () => { + it('calls navigator.share with file when available', async () => { const mockShare = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'share', { value: mockShare, @@ -116,20 +150,36 @@ describe('ReceivePage', () => { await userEvent.click(screen.getByText('分享')) - expect(mockShare).toHaveBeenCalledWith({ - title: 'BFM Pay 收款地址', - text: mockAddress, + // Wait for async operations + await vi.waitFor(() => { + expect(mockShare).toHaveBeenCalled() }) - expect(mockHapticsImpact).toHaveBeenCalledWith('success') + + // Check that share was called with files array + const shareCall = mockShare.mock.calls[0][0] + expect(shareCall.title).toBe('BFM Pay 收款地址') + expect(shareCall.text).toBe(mockAddress) + expect(shareCall.files).toBeInstanceOf(Array) + expect(shareCall.files[0]).toBeInstanceOf(File) + }) + }) + + describe('Save Image Functionality', () => { + it('renders save image button', () => { + renderWithProviders() + expect(screen.getByText('保存图片')).toBeInTheDocument() }) }) describe('QR Code', () => { - it('renders QR code component', () => { + it('renders actual QR code SVG, not placeholder', () => { renderWithProviders() - // QR code should render with the address - const qrContainer = screen.getByText('扫描二维码向此地址转账').parentElement - expect(qrContainer).toBeInTheDocument() + // QR code should render as SVG element from qrcode.react + const svg = document.querySelector('svg') + expect(svg).toBeInTheDocument() + // Placeholder text should NOT be present + expect(screen.queryByText(/^QR:/)).not.toBeInTheDocument() }) }) }) + diff --git a/src/pages/receive/index.tsx b/src/pages/receive/index.tsx index 5b040ae9b..a4f085252 100644 --- a/src/pages/receive/index.tsx +++ b/src/pages/receive/index.tsx @@ -1,14 +1,22 @@ -import { useState } from 'react'; +import { useState, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation } from '@/stackflow'; import { PageHeader } from '@/components/layout/page-header'; import { AddressDisplay } from '@/components/wallet/address-display'; -import { AddressQRCode, Alert, GradientButton } from '@/components/common'; +import { ContactCard } from '@/components/contact/contact-card'; +import { Alert, GradientButton } from '@/components/common'; import { ChainIcon } from '@/components/wallet/chain-icon'; import { Button } from '@/components/ui/button'; import { useClipboard, useToast, useHaptics } from '@/services'; -import { IconCopy as Copy, IconShare2 as Share2, IconCheck as Check } from '@tabler/icons-react'; -import { useCurrentChainAddress, useSelectedChain, type ChainType } from '@/stores'; +import { generateContactQRContent } from '@/lib/qr-parser'; +import { + IconCopy as Copy, + IconShare2 as Share2, + IconCheck as Check, + IconDownload as Download, + IconLoader2 as Loader, +} from '@tabler/icons-react'; +import { useCurrentChainAddress, useSelectedChain, useUserProfile, type ChainType } from '@/stores'; const CHAIN_NAMES: Record = { ethereum: 'Ethereum', @@ -26,7 +34,7 @@ const CHAIN_NAMES: Record = { }; export function ReceivePage() { - const { t } = useTranslation('transaction'); + const { t } = useTranslation(['transaction', 'common']); const { goBack } = useNavigation(); const clipboard = useClipboard(); const toast = useToast(); @@ -35,10 +43,27 @@ export function ReceivePage() { const chainAddress = useCurrentChainAddress(); const selectedChain = useSelectedChain(); const selectedChainName = CHAIN_NAMES[selectedChain] ?? selectedChain; + const profile = useUserProfile(); + const [copied, setCopied] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const qrCardRef = useRef(null); const address = chainAddress?.address || ''; + // Generate display name + const displayName = profile.username || t('common:myCard.defaultName'); + + // Generate QR content using contact protocol + const qrContent = useMemo(() => { + if (!address) return ''; + return generateContactQRContent({ + name: displayName, + addresses: [{ address, label: selectedChainName }], + avatar: profile.avatar, + }); + }, [displayName, address, selectedChainName, profile.avatar]); + const handleCopy = async () => { if (address) { await clipboard.write({ text: address }); @@ -49,19 +74,53 @@ export function ReceivePage() { } }; - const handleShare = async () => { - if (navigator.share && address) { - try { + const handleDownload = useCallback(async () => { + const cardElement = qrCardRef.current; + if (!cardElement || isProcessing) return; + + setIsProcessing(true); + try { + const { snapdom } = await import('@zumer/snapdom'); + await snapdom.download(cardElement, { + type: 'png', + filename: `receive-${selectedChain}-${address.slice(0, 8)}.png`, + scale: 2, + quality: 1, + }); + await haptics.impact('success'); + toast.show(t('receivePage.imageSaved')); + } catch { + // Download failed silently + } finally { + setIsProcessing(false); + } + }, [address, selectedChain, isProcessing, haptics, toast, t]); + + const handleShare = useCallback(async () => { + const cardElement = qrCardRef.current; + if (!cardElement || isProcessing) return; + + setIsProcessing(true); + try { + if (navigator.share) { + const { snapdom } = await import('@zumer/snapdom'); + const result = await snapdom(cardElement, { scale: 2 }); + const blob = await result.toBlob(); + const file = new File([blob], `receive-${selectedChain}.png`, { type: 'image/png' }); + await navigator.share({ title: t('receivePage.shareTitle'), text: address, + files: [file], }); await haptics.impact('success'); - } catch { - // User cancelled share } + } catch { + // User cancelled share + } finally { + setIsProcessing(false); } - }; + }, [address, selectedChain, isProcessing, haptics, t]); return (
@@ -74,10 +133,14 @@ export function ReceivePage() { {selectedChainName}
- {/* QR code area */} -
- -

{t('receivePage.scanQrCode')}

+ {/* QR code area using ContactCard - wrapped for screenshot */} +
+
{/* Address display */} @@ -103,12 +166,22 @@ export function ReceivePage() { )} - - - {t('receivePage.share')} - +
+ {/* Share button - full width */} + + {isProcessing ? : } + {t('receivePage.share')} + + {/* Warning */} {t('receivePage.networkWarning', { chain: selectedChainName })}
diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index af1aee480..3d26d5e65 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -220,6 +220,9 @@ function SendPageContent() { let address = content; if (parsed.type === 'address' || parsed.type === 'payment') { address = parsed.address; + } else if (parsed.type === 'contact' && parsed.addresses.length > 0) { + // 从名片中提取第一个地址 + address = parsed.addresses[0].address; } setToAddress(address); haptics.impact('success'); diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index d7314f942..e2a727f90 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -17,9 +17,11 @@ import { IconInfoCircle as Info, IconDatabase as Database, IconWorld, + IconIdBadge2, } from '@tabler/icons-react'; import { PageHeader } from '@/components/layout/page-header'; -import { useCurrentWallet, useLanguage, useCurrency, useTheme, chainConfigStore, chainConfigSelectors } from '@/stores'; +import { useCurrentWallet, useLanguage, useCurrency, useTheme, chainConfigStore, chainConfigSelectors, useUserProfile } from '@/stores'; +import { ContactAvatar } from '@/components/common/contact-avatar'; import { SettingsItem } from './settings-item'; import { SettingsSection } from './settings-section'; import { AppearanceSheet } from '@/components/settings/appearance-sheet'; @@ -50,6 +52,7 @@ export function SettingsPage() { const { push } = useFlow(); const { t } = useTranslation(['settings', 'common', 'security']); const currentWallet = useCurrentWallet(); + const profile = useUserProfile(); const currentLanguage = useLanguage(); const currentCurrency = useCurrency(); const currentTheme = useTheme(); @@ -166,22 +169,23 @@ export function SettingsPage() { return (
-
- {/* 钱包信息头 */} - {currentWallet && ( -
-
- {currentWallet.name.slice(0, 1)} -
-
-

{currentWallet.name}

-

- {t('settings:chainAddressCount', { count: currentWallet.chainAddresses.length })} -

-
+ {/* 我的名片 - 头部入口 */} + {/* 钱包管理 */} @@ -300,6 +304,6 @@ export function SettingsPage() { open={appearanceSheetOpen} onOpenChange={setAppearanceSheetOpen} /> -
+
); } diff --git a/src/services/chain-adapter/providers/biowallet-provider.ts b/src/services/chain-adapter/providers/biowallet-provider.ts index 7dc225f2d..7845984dc 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.ts @@ -148,9 +148,9 @@ function getDirection(from: string, to: string, address: string): Direction { return 'in' } -// BFM 链的 epoch 时间(2017-01-01T00:00:00Z 的毫秒时间戳) -// BFM timestamp 是从这个时间点开始的秒数偏移量 -const BFM_EPOCH_MS = new Date('2017-01-01T00:00:00Z').getTime() +// 默认 epoch 时间(用于未配置 genesis block 的链,此值不应该被使用) +// 每个链的真实 epoch 应该从创世块的 beginEpochTime 读取 +const DEFAULT_EPOCH_MS = 0 /** * 检测 BioForest 交易类型并映射到标准 Action @@ -267,17 +267,18 @@ function convertBioTransactionToTransaction( status: 'pending' | 'confirmed' | 'failed' createdTime?: string // pending 交易的创建时间 address?: string // 当前钱包地址,用于判断方向 + epochMs: number // 链的创世时间(毫秒),从 genesis block 的 beginEpochTime 获取 } ): Transaction { - const { signature, height, status, createdTime, address = '' } = options + const { signature, height, status, createdTime, address = '', epochMs } = options // 提取资产信息 const { value, assetType } = extractAssetInfo(bioTx.asset, 'BFM') - // 计算时间戳 + // 计算时间戳:使用链特定的 epoch 时间 const timestamp = createdTime ? new Date(createdTime).getTime() - : BFM_EPOCH_MS + bioTx.timestamp * 1000 + : epochMs + bioTx.timestamp * 1000 // 判断方向 const direction = address @@ -325,6 +326,7 @@ export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMi private readonly symbol: string private readonly decimals: number private forgeInterval: number = 15000 // 默认 15s + private epochMs: number = DEFAULT_EPOCH_MS // 链的创世时间(毫秒),从 genesis block 的 beginEpochTime 获取 // ==================== 私有基础 Fetcher ==================== @@ -350,17 +352,22 @@ export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMi // ==================== 链配置读取(创世块) ==================== - // 从静态配置中读取创世块以获取 forgeInterval + // 从静态配置中读取创世块以获取 forgeInterval 和 beginEpochTime const genesisPath = chainConfigService.getBiowalletGenesisBlock(chainId) if (genesisPath) { fetchGenesisBlock(chainId, genesisPath) .then(genesis => { - // Genesis Block JSON 可能直接包含 forgeInterval + // Genesis Block JSON 包含 forgeInterval 和 beginEpochTime const interval = genesis.asset.genesisAsset.forgeInterval if (typeof interval === 'number') { this.forgeInterval = interval * 1000 setForgeInterval(chainId, this.forgeInterval) } + // 读取创世块的 beginEpochTime 作为 timestamp 的基准 + const beginEpochTime = genesis.asset.genesisAsset.beginEpochTime + if (typeof beginEpochTime === 'number') { + this.epochMs = beginEpochTime + } }) .catch(err => { console.warn('Failed to fetch genesis block:', err) @@ -498,6 +505,8 @@ export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMi // 交易历史:从 #txList 派生 // 原版逻辑:使用 detectAction 和 extractAssetInfo + // 使用闭包捕获 this 以便在 transform 中访问动态获取的 epochMs + const provider = this this.transactionHistory = derive({ name: `biowallet.${chainId}.transactionHistory`, source: this.#txList, @@ -522,7 +531,7 @@ export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMi hash: tx.signature ?? item.signature, from: tx.senderId, to: tx.recipientId ?? '', - timestamp: BFM_EPOCH_MS + tx.timestamp * 1000, // BFM timestamp 是从 2017-01-01 开始的秒数 + timestamp: provider.epochMs + tx.timestamp * 1000, // 使用链特定的 epoch 时间 status: 'confirmed', blockNumber: BigInt(item.height), action, @@ -611,6 +620,7 @@ export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMi signature: pendingTx.trJson.signature ?? pendingTx.signature ?? '', status: 'pending', createdTime: pendingTx.createdTime, + epochMs: provider.epochMs, }) } } @@ -622,6 +632,7 @@ export class BiowalletProvider extends BioforestAccountMixin(BioforestIdentityMi signature: item.transaction.signature ?? item.signature, height: item.height, status: 'confirmed', + epochMs: provider.epochMs, }) } diff --git a/src/stackflow/activities/MyCardActivity.tsx b/src/stackflow/activities/MyCardActivity.tsx new file mode 100644 index 000000000..c7a4ab5cd --- /dev/null +++ b/src/stackflow/activities/MyCardActivity.tsx @@ -0,0 +1,11 @@ +import type { ActivityComponentType } from '@stackflow/react'; +import { AppScreen } from '@stackflow/plugin-basic-ui'; +import { MyCardPage } from '@/pages/my-card'; + +export const MyCardActivity: ActivityComponentType = () => { + return ( + + + + ); +}; diff --git a/src/stackflow/hooks/use-navigation.ts b/src/stackflow/hooks/use-navigation.ts index e5cce8ec1..54cf3c684 100644 --- a/src/stackflow/hooks/use-navigation.ts +++ b/src/stackflow/hooks/use-navigation.ts @@ -20,6 +20,7 @@ const routeToActivityMap: Record = { "/scanner": "ScannerActivity", "/onboarding/recover": "OnboardingRecoverActivity", "/address-book": "AddressBookActivity", + "/my-card": "MyCardActivity", "/notifications": "NotificationsActivity", "/staking": "StakingActivity", "/welcome": "WelcomeActivity", diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts index 9f41f8cd8..11de79d2c 100644 --- a/src/stackflow/stackflow.ts +++ b/src/stackflow/stackflow.ts @@ -25,6 +25,7 @@ import { AuthorizeSignatureActivity } from './activities/AuthorizeSignatureActiv import { OnboardingRecoverActivity } from './activities/OnboardingRecoverActivity'; import { TokenDetailActivity } from './activities/TokenDetailActivity'; import { AddressBookActivity } from './activities/AddressBookActivity'; +import { MyCardActivity } from './activities/MyCardActivity'; import { NotificationsActivity } from './activities/NotificationsActivity'; import { StakingActivity } from './activities/StakingActivity'; import { WelcomeActivity } from './activities/WelcomeActivity'; @@ -95,6 +96,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ OnboardingRecoverActivity: '/onboarding/recover', TokenDetailActivity: '/token/:tokenId', AddressBookActivity: '/address-book', + MyCardActivity: '/my-card', NotificationsActivity: '/notifications', StakingActivity: '/staking', WelcomeActivity: '/welcome', @@ -161,6 +163,7 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ OnboardingRecoverActivity, TokenDetailActivity, AddressBookActivity, + MyCardActivity, NotificationsActivity, StakingActivity, WelcomeActivity, diff --git a/src/stores/index.ts b/src/stores/index.ts index 00b701f9a..1ff174f5a 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -6,6 +6,20 @@ export type { Wallet, ChainType, ChainAddress, WalletState } from './wallet' export { addressBookStore, addressBookActions, addressBookSelectors } from './address-book' export type { Contact, ContactAddress, ContactSuggestion, AddressBookState } from './address-book' +// User Profile Store +export { + userProfileStore, + userProfileActions, + userProfileSelectors, + useUserProfile, + useUsername, + useAvatar, + useSelectedWalletIds, + useIsWalletSelected, + useCanAddMoreWallets, +} from './user-profile' +export type { UserProfile } from './user-profile' + // Preferences Store export { preferencesStore, diff --git a/src/stores/user-profile.test.ts b/src/stores/user-profile.test.ts new file mode 100644 index 000000000..aaaf254be --- /dev/null +++ b/src/stores/user-profile.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { userProfileStore, userProfileActions, userProfileSelectors } from './user-profile' + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value }), + removeItem: vi.fn((key: string) => { delete store[key] }), + clear: vi.fn(() => { store = {} }), + } +})() + +Object.defineProperty(global, 'localStorage', { value: localStorageMock }) + +describe('userProfileStore', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + // Reset store to initial state + userProfileActions.clear() + }) + + describe('initial state', () => { + it('has empty username by default', () => { + expect(userProfileStore.state.username).toBe('') + }) + + it('has empty avatar by default', () => { + expect(userProfileStore.state.avatar).toBe('') + }) + + it('has empty selectedWalletIds by default', () => { + expect(userProfileStore.state.selectedWalletIds).toEqual([]) + }) + }) + + describe('setUsername', () => { + it('sets username', () => { + userProfileActions.setUsername('Alice') + expect(userProfileStore.state.username).toBe('Alice') + }) + + it('trims whitespace', () => { + userProfileActions.setUsername(' Bob ') + expect(userProfileStore.state.username).toBe('Bob') + }) + + it('persists to localStorage', () => { + userProfileActions.setUsername('Charlie') + expect(localStorageMock.setItem).toHaveBeenCalled() + const stored = JSON.parse(localStorageMock.setItem.mock.calls.at(-1)?.[1] ?? '{}') + expect(stored.username).toBe('Charlie') + }) + }) + + describe('randomizeAvatar', () => { + it('generates avatar with avatar: prefix', () => { + userProfileActions.randomizeAvatar() + expect(userProfileStore.state.avatar).toMatch(/^avatar:.+$/) + }) + + it('generates different avatars on each call', () => { + userProfileActions.randomizeAvatar() + const first = userProfileStore.state.avatar + userProfileActions.randomizeAvatar() + const second = userProfileStore.state.avatar + // Note: There's a small chance they could be the same, but very unlikely + expect(first).not.toBe(second) + }) + }) + + describe('initializeDefaultAvatar', () => { + it('sets avatar from address if not already set', () => { + expect(userProfileStore.state.avatar).toBe('') + userProfileActions.initializeDefaultAvatar('0x1234567890abcdef') + expect(userProfileStore.state.avatar).toMatch(/^avatar:.+$/) + }) + + it('does not override existing avatar', () => { + userProfileActions.randomizeAvatar() + const existing = userProfileStore.state.avatar + userProfileActions.initializeDefaultAvatar('0xabcdef1234567890') + expect(userProfileStore.state.avatar).toBe(existing) + }) + + it('generates consistent avatar for same address', () => { + userProfileActions.initializeDefaultAvatar('0x1234567890abcdef') + const first = userProfileStore.state.avatar + userProfileActions.clear() + userProfileActions.initializeDefaultAvatar('0x1234567890abcdef') + const second = userProfileStore.state.avatar + expect(first).toBe(second) + }) + }) + + describe('toggleWalletSelection', () => { + it('adds wallet to selection', () => { + const added = userProfileActions.toggleWalletSelection('wallet-1') + expect(added).toBe(true) + expect(userProfileStore.state.selectedWalletIds).toContain('wallet-1') + }) + + it('removes wallet when already selected', () => { + userProfileActions.toggleWalletSelection('wallet-1') + const added = userProfileActions.toggleWalletSelection('wallet-1') + expect(added).toBe(false) + expect(userProfileStore.state.selectedWalletIds).not.toContain('wallet-1') + }) + + it('allows up to 3 wallets', () => { + userProfileActions.toggleWalletSelection('wallet-1') + userProfileActions.toggleWalletSelection('wallet-2') + userProfileActions.toggleWalletSelection('wallet-3') + expect(userProfileStore.state.selectedWalletIds).toHaveLength(3) + }) + + it('prevents adding more than 3 wallets', () => { + userProfileActions.toggleWalletSelection('wallet-1') + userProfileActions.toggleWalletSelection('wallet-2') + userProfileActions.toggleWalletSelection('wallet-3') + const added = userProfileActions.toggleWalletSelection('wallet-4') + expect(added).toBe(false) + expect(userProfileStore.state.selectedWalletIds).toHaveLength(3) + expect(userProfileStore.state.selectedWalletIds).not.toContain('wallet-4') + }) + }) + + describe('setSelectedWalletIds', () => { + it('sets wallet IDs directly', () => { + userProfileActions.setSelectedWalletIds(['a', 'b']) + expect(userProfileStore.state.selectedWalletIds).toEqual(['a', 'b']) + }) + + it('truncates to 3 wallets', () => { + userProfileActions.setSelectedWalletIds(['a', 'b', 'c', 'd', 'e']) + expect(userProfileStore.state.selectedWalletIds).toEqual(['a', 'b', 'c']) + }) + }) + + describe('selectors', () => { + it('isWalletSelected returns true for selected wallet', () => { + userProfileActions.toggleWalletSelection('wallet-1') + expect(userProfileSelectors.isWalletSelected(userProfileStore.state, 'wallet-1')).toBe(true) + }) + + it('isWalletSelected returns false for unselected wallet', () => { + expect(userProfileSelectors.isWalletSelected(userProfileStore.state, 'wallet-1')).toBe(false) + }) + + it('canAddMoreWallets returns true when under limit', () => { + userProfileActions.toggleWalletSelection('wallet-1') + expect(userProfileSelectors.canAddMoreWallets(userProfileStore.state)).toBe(true) + }) + + it('canAddMoreWallets returns false when at limit', () => { + userProfileActions.toggleWalletSelection('wallet-1') + userProfileActions.toggleWalletSelection('wallet-2') + userProfileActions.toggleWalletSelection('wallet-3') + expect(userProfileSelectors.canAddMoreWallets(userProfileStore.state)).toBe(false) + }) + + it('getSelectedWalletCount returns correct count', () => { + userProfileActions.toggleWalletSelection('wallet-1') + userProfileActions.toggleWalletSelection('wallet-2') + expect(userProfileSelectors.getSelectedWalletCount(userProfileStore.state)).toBe(2) + }) + }) + + describe('clear', () => { + it('resets all state to initial values', () => { + userProfileActions.setUsername('Test') + userProfileActions.randomizeAvatar() + userProfileActions.toggleWalletSelection('wallet-1') + + userProfileActions.clear() + + expect(userProfileStore.state.username).toBe('') + expect(userProfileStore.state.avatar).toBe('') + expect(userProfileStore.state.selectedWalletIds).toEqual([]) + }) + }) +}) diff --git a/src/stores/user-profile.ts b/src/stores/user-profile.ts new file mode 100644 index 000000000..609943a07 --- /dev/null +++ b/src/stores/user-profile.ts @@ -0,0 +1,210 @@ +/** + * User Profile Store + * + * Stores global user profile for business card sharing: + * - username: User-defined display name (default: empty) + * - avatar: Avatar in avatar:HASH format (Avataaars encoding) + * - selectedWalletIds: Up to 3 wallet IDs for business card QR + */ + +import { Store } from '@tanstack/react-store' +import { useStore } from '@tanstack/react-store' +import { generateRandomAvatar, encodeAvatar, generateAvatarFromSeed } from '@/lib/avatar-codec' + +// Types +export interface UserProfile { + /** User-defined display name (default: empty) */ + username: string + /** Avatar in avatar:HASH format */ + avatar: string + /** Selected wallet IDs for business card (max 3) */ + selectedWalletIds: string[] +} + +// Storage key +const STORAGE_KEY = 'bfm_user_profile' +const MAX_WALLETS = 3 + +// Initial state +const initialState: UserProfile = { + username: '', + avatar: '', + selectedWalletIds: [], +} + +// Load from localStorage +function loadFromStorage(): UserProfile { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + return { + username: parsed.username ?? '', + avatar: parsed.avatar ?? '', + selectedWalletIds: Array.isArray(parsed.selectedWalletIds) + ? parsed.selectedWalletIds.slice(0, MAX_WALLETS) + : [], + } + } + } catch { + // Ignore parse errors + } + return initialState +} + +// Save to localStorage +function saveToStorage(state: UserProfile): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch { + // Ignore storage errors + } +} + +// Create store +export const userProfileStore = new Store(loadFromStorage()) + +// Actions +export const userProfileActions = { + /** + * Set username + */ + setUsername(username: string): void { + userProfileStore.setState((state) => { + const newState = { ...state, username: username.trim() } + saveToStorage(newState) + return newState + }) + }, + + /** + * Randomize avatar - generates a new random Avataaars avatar + */ + randomizeAvatar(): void { + const config = generateRandomAvatar() + const avatar = `avatar:${encodeAvatar(config)}` + userProfileStore.setState((state) => { + const newState = { ...state, avatar } + saveToStorage(newState) + return newState + }) + }, + + /** + * Initialize default avatar from address (only if not already set) + */ + initializeDefaultAvatar(address: string): void { + userProfileStore.setState((state) => { + if (state.avatar) return state // Already has avatar + + const config = generateAvatarFromSeed(address.toLowerCase()) + const avatar = `avatar:${encodeAvatar(config)}` + const newState = { ...state, avatar } + saveToStorage(newState) + return newState + }) + }, + + /** + * Toggle wallet selection (add/remove from selectedWalletIds) + * Maximum 3 wallets can be selected + */ + toggleWalletSelection(walletId: string): boolean { + let added = false + userProfileStore.setState((state) => { + const currentIds = state.selectedWalletIds + const isSelected = currentIds.includes(walletId) + + let newIds: string[] + if (isSelected) { + // Remove + newIds = currentIds.filter(id => id !== walletId) + } else { + // Add (if under limit) + if (currentIds.length >= MAX_WALLETS) { + return state // Can't add more + } + newIds = [...currentIds, walletId] + added = true + } + + const newState = { ...state, selectedWalletIds: newIds } + saveToStorage(newState) + return newState + }) + return added + }, + + /** + * Set selected wallet IDs directly (for initialization) + */ + setSelectedWalletIds(walletIds: string[]): void { + userProfileStore.setState((state) => { + const newState = { + ...state, + selectedWalletIds: walletIds.slice(0, MAX_WALLETS) + } + saveToStorage(newState) + return newState + }) + }, + + /** + * Clear all user profile data + */ + clear(): void { + userProfileStore.setState(() => { + saveToStorage(initialState) + return initialState + }) + }, +} + +// Selectors +export const userProfileSelectors = { + /** + * Check if a wallet is selected + */ + isWalletSelected(state: UserProfile, walletId: string): boolean { + return state.selectedWalletIds.includes(walletId) + }, + + /** + * Check if can add more wallets + */ + canAddMoreWallets(state: UserProfile): boolean { + return state.selectedWalletIds.length < MAX_WALLETS + }, + + /** + * Get selected wallet count + */ + getSelectedWalletCount(state: UserProfile): number { + return state.selectedWalletIds.length + }, +} + +// Hooks +export function useUserProfile(): UserProfile { + return useStore(userProfileStore) +} + +export function useUsername(): string { + return useStore(userProfileStore, (state) => state.username) +} + +export function useAvatar(): string { + return useStore(userProfileStore, (state) => state.avatar) +} + +export function useSelectedWalletIds(): string[] { + return useStore(userProfileStore, (state) => state.selectedWalletIds) +} + +export function useIsWalletSelected(walletId: string): boolean { + return useStore(userProfileStore, (state) => state.selectedWalletIds.includes(walletId)) +} + +export function useCanAddMoreWallets(): boolean { + return useStore(userProfileStore, (state) => state.selectedWalletIds.length < MAX_WALLETS) +}