Skip to content

Commit 05ea4f4

Browse files
committed
Stablecoin payment settings
1 parent d474715 commit 05ea4f4

8 files changed

Lines changed: 564 additions & 3 deletions

File tree

.kiro/specs/workwork-ledger-mvp/tasks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,12 @@
256256
- 创建包含链信息的账本条目
257257
- _需求: 6.5, 7.2_
258258

259-
- [ ] 16. 稳定币收款设置
260-
- [ ] 16.1 实现稳定币设置 UI
259+
- [x] 16. 稳定币收款设置
260+
- [x] 16.1 实现稳定币设置 UI
261261
- 资产选择(USDC/USDT)
262262
- 链选择(Arbitrum/Base/Polygon)
263263
- _需求: 2.3_
264-
- [ ] 16.2 实现设置保存和地址生成
264+
- [x] 16.2 实现设置保存和地址生成
265265
- 保存配置后自动生成地址
266266
- _需求: 2.4_
267267

src/app/settings/page.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Navbar } from '@/components/layout/navbar';
5+
import { CryptoSettingsForm } from '@/components/settings/crypto-settings-form';
6+
7+
// Temporary user ID for demo - will be replaced with auth
8+
const DEMO_USER_ID = 'demo-user-id';
9+
10+
type SettingsTab = 'general' | 'crypto';
11+
12+
export default function SettingsPage() {
13+
const [activeTab, setActiveTab] = useState<SettingsTab>('crypto');
14+
15+
return (
16+
<div className="min-h-screen bg-gray-50">
17+
<Navbar />
18+
<div className="container mx-auto px-4 py-8">
19+
<h1 className="text-2xl font-bold text-gray-900 mb-6">设置</h1>
20+
21+
{/* Tab Navigation */}
22+
<div className="border-b border-gray-200 mb-6">
23+
<nav className="-mb-px flex space-x-8">
24+
<button
25+
onClick={() => setActiveTab('general')}
26+
className={`py-2 px-1 border-b-2 font-medium text-sm ${
27+
activeTab === 'general'
28+
? 'border-blue-500 text-blue-600'
29+
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
30+
}`}
31+
>
32+
基本设置
33+
</button>
34+
<button
35+
onClick={() => setActiveTab('crypto')}
36+
className={`py-2 px-1 border-b-2 font-medium text-sm ${
37+
activeTab === 'crypto'
38+
? 'border-blue-500 text-blue-600'
39+
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
40+
}`}
41+
>
42+
稳定币收款
43+
</button>
44+
</nav>
45+
</div>
46+
47+
{/* Tab Content */}
48+
<div className="bg-white rounded-lg shadow p-6">
49+
{activeTab === 'general' && (
50+
<div className="text-gray-500">
51+
基本设置功能开发中...
52+
</div>
53+
)}
54+
{activeTab === 'crypto' && (
55+
<CryptoSettingsForm userId={DEMO_USER_ID} />
56+
)}
57+
</div>
58+
</div>
59+
</div>
60+
);
61+
}

src/components/layout/navbar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export function Navbar() {
3535
<Link href="/invoices" className={linkClass('/invoices')}>
3636
发票
3737
</Link>
38+
<Link href="/settings" className={linkClass('/settings')}>
39+
设置
40+
</Link>
3841
</div>
3942
</div>
4043
</div>
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import { Button } from '@/components/ui/button';
5+
import { trpc } from '@/trpc/client';
6+
import type { Chain, StablecoinAsset } from '@/types/domain';
7+
8+
interface CryptoSettingsFormProps {
9+
userId: string;
10+
}
11+
12+
13+
14+
/**
15+
* Chain display names
16+
*/
17+
const CHAIN_LABELS: Record<Chain, string> = {
18+
arbitrum: 'Arbitrum',
19+
base: 'Base',
20+
polygon: 'Polygon',
21+
};
22+
23+
/**
24+
* Asset display names
25+
*/
26+
const ASSET_LABELS: Record<StablecoinAsset, string> = {
27+
USDC: 'USDC',
28+
USDT: 'USDT',
29+
};
30+
31+
/**
32+
* Crypto Settings Form Component
33+
* Implements requirements 2.3, 2.4
34+
*/
35+
export function CryptoSettingsForm({ userId }: CryptoSettingsFormProps) {
36+
const [enabled, setEnabled] = useState(false);
37+
const [selectedAssets, setSelectedAssets] = useState<StablecoinAsset[]>([]);
38+
const [selectedChains, setSelectedChains] = useState<Chain[]>([]);
39+
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
40+
41+
// Fetch current settings
42+
const settingsQuery = trpc.crypto.getSettings.useQuery({ userId });
43+
const addressesQuery = trpc.crypto.getAddresses.useQuery({ userId });
44+
const optionsQuery = trpc.crypto.getOptions.useQuery();
45+
46+
// Save mutation
47+
const saveMutation = trpc.crypto.saveSettings.useMutation({
48+
onSuccess: () => {
49+
setSaveMessage({ type: 'success', text: '设置已保存,收款地址已生成' });
50+
addressesQuery.refetch();
51+
setTimeout(() => setSaveMessage(null), 3000);
52+
},
53+
onError: (error) => {
54+
setSaveMessage({ type: 'error', text: `保存失败: ${error.message}` });
55+
setTimeout(() => setSaveMessage(null), 5000);
56+
},
57+
});
58+
59+
// Initialize form with current settings
60+
useEffect(() => {
61+
if (settingsQuery.data) {
62+
setEnabled(settingsQuery.data.enabled);
63+
setSelectedAssets(settingsQuery.data.enabledAssets);
64+
setSelectedChains(settingsQuery.data.enabledChains);
65+
}
66+
}, [settingsQuery.data]);
67+
68+
const handleAssetToggle = (asset: StablecoinAsset) => {
69+
setSelectedAssets(prev =>
70+
prev.includes(asset)
71+
? prev.filter(a => a !== asset)
72+
: [...prev, asset]
73+
);
74+
};
75+
76+
const handleChainToggle = (chain: Chain) => {
77+
setSelectedChains(prev =>
78+
prev.includes(chain)
79+
? prev.filter(c => c !== chain)
80+
: [...prev, chain]
81+
);
82+
};
83+
84+
const handleSave = async () => {
85+
await saveMutation.mutateAsync({
86+
userId,
87+
settings: {
88+
enabled,
89+
enabledAssets: selectedAssets,
90+
enabledChains: selectedChains,
91+
},
92+
});
93+
};
94+
95+
const canSave = enabled ? selectedAssets.length > 0 && selectedChains.length > 0 : true;
96+
97+
if (settingsQuery.isLoading || optionsQuery.isLoading) {
98+
return <div className="text-gray-500">加载中...</div>;
99+
}
100+
101+
return (
102+
<div className="space-y-6">
103+
{/* Enable/Disable Toggle */}
104+
<div className="flex items-center justify-between">
105+
<div>
106+
<h3 className="text-lg font-medium text-gray-900">启用稳定币收款</h3>
107+
<p className="text-sm text-gray-500">允许客户使用 USDC/USDT 支付发票</p>
108+
</div>
109+
<button
110+
type="button"
111+
onClick={() => setEnabled(!enabled)}
112+
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
113+
enabled ? 'bg-blue-600' : 'bg-gray-200'
114+
}`}
115+
role="switch"
116+
aria-checked={enabled}
117+
>
118+
<span
119+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
120+
enabled ? 'translate-x-5' : 'translate-x-0'
121+
}`}
122+
/>
123+
</button>
124+
</div>
125+
126+
{enabled && (
127+
<>
128+
{/* Asset Selection */}
129+
<div>
130+
<h4 className="text-sm font-medium text-gray-900 mb-3">选择支持的资产</h4>
131+
<p className="text-sm text-gray-500 mb-3">选择您希望接收的稳定币类型</p>
132+
<div className="flex flex-wrap gap-3">
133+
{optionsQuery.data?.assets.map(({ value, label }) => (
134+
<button
135+
key={value}
136+
type="button"
137+
onClick={() => handleAssetToggle(value)}
138+
className={`px-4 py-2 rounded-lg border-2 font-medium transition-colors ${
139+
selectedAssets.includes(value)
140+
? 'border-blue-500 bg-blue-50 text-blue-700'
141+
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
142+
}`}
143+
>
144+
{label}
145+
</button>
146+
))}
147+
</div>
148+
{enabled && selectedAssets.length === 0 && (
149+
<p className="mt-2 text-sm text-red-500">请至少选择一种资产</p>
150+
)}
151+
</div>
152+
153+
{/* Chain Selection */}
154+
<div>
155+
<h4 className="text-sm font-medium text-gray-900 mb-3">选择支持的链</h4>
156+
<p className="text-sm text-gray-500 mb-3">选择您希望在哪些区块链上接收付款</p>
157+
<div className="flex flex-wrap gap-3">
158+
{optionsQuery.data?.chains.map(({ value, label }) => (
159+
<button
160+
key={value}
161+
type="button"
162+
onClick={() => handleChainToggle(value)}
163+
className={`px-4 py-2 rounded-lg border-2 font-medium transition-colors ${
164+
selectedChains.includes(value)
165+
? 'border-blue-500 bg-blue-50 text-blue-700'
166+
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
167+
}`}
168+
>
169+
{label}
170+
</button>
171+
))}
172+
</div>
173+
{enabled && selectedChains.length === 0 && (
174+
<p className="mt-2 text-sm text-red-500">请至少选择一条链</p>
175+
)}
176+
</div>
177+
</>
178+
)}
179+
180+
{/* Save Button */}
181+
<div className="flex items-center gap-4 pt-4 border-t">
182+
<Button
183+
onClick={handleSave}
184+
disabled={!canSave || saveMutation.isPending}
185+
>
186+
{saveMutation.isPending ? '保存中...' : '保存设置'}
187+
</Button>
188+
{saveMessage && (
189+
<span className={`text-sm ${saveMessage.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
190+
{saveMessage.text}
191+
</span>
192+
)}
193+
</div>
194+
195+
{/* Generated Addresses */}
196+
{enabled && addressesQuery.data && addressesQuery.data.length > 0 && (
197+
<div className="pt-6 border-t">
198+
<h4 className="text-sm font-medium text-gray-900 mb-3">已生成的收款地址</h4>
199+
<div className="space-y-3">
200+
{addressesQuery.data.map((addr) => (
201+
<div
202+
key={addr.id}
203+
className="p-3 bg-gray-50 rounded-lg border border-gray-200"
204+
>
205+
<div className="flex items-center gap-2 mb-1">
206+
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 rounded">
207+
{CHAIN_LABELS[addr.chain]}
208+
</span>
209+
<span className="text-xs font-medium px-2 py-0.5 bg-green-100 text-green-700 rounded">
210+
{ASSET_LABELS[addr.asset]}
211+
</span>
212+
</div>
213+
<code className="text-sm text-gray-600 break-all">{addr.address}</code>
214+
</div>
215+
))}
216+
</div>
217+
</div>
218+
)}
219+
</div>
220+
);
221+
}

0 commit comments

Comments
 (0)