diff --git a/apps/web/src/app/layout/Sidebar.tsx b/apps/web/src/app/layout/Sidebar.tsx index 6e605a8..127edd2 100644 --- a/apps/web/src/app/layout/Sidebar.tsx +++ b/apps/web/src/app/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { playInterfaceSound } from '../../shared/sound/interactionSounds' import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { Languages, + ArrowLeftRight, Bot, Columns3, Orbit, @@ -147,6 +148,7 @@ const Sidebar = ({ collapsed, onToggle }: SidebarProps) => { const FEATURE_ICONS: Record = { bazi: Columns3, astrology: Orbit, tarot: Layers, translation: Languages, + 'currency-exchange': ArrowLeftRight, 'habit-tracker': Flame, 'ai-digest': Sparkles, automation: Bot, @@ -156,6 +158,7 @@ const Sidebar = ({ collapsed, onToggle }: SidebarProps) => { const FEATURE_ROUTES: Record = { bazi: ROUTES.BAZI, astrology: ROUTES.ASTROLOGY, tarot: ROUTES.TAROT, translation: ROUTES.TRANSLATION, + 'currency-exchange': ROUTES.CURRENCY_EXCHANGE, 'habit-tracker': ROUTES.HABITS, 'ai-digest': ROUTES.LABS, automation: ROUTES.LABS, diff --git a/apps/web/src/app/routes/AppRoutes.tsx b/apps/web/src/app/routes/AppRoutes.tsx index cbd7a15..3bca376 100644 --- a/apps/web/src/app/routes/AppRoutes.tsx +++ b/apps/web/src/app/routes/AppRoutes.tsx @@ -31,6 +31,7 @@ const FocusPage = lazy(() => import('../../features/focus/pages/FocusPage')) const DiaryPage = lazy(() => import('../../features/diary/pages/DiaryPage')) const SettingsRoute = lazy(() => import('./SettingsRoute')) const TranslationPage = lazy(() => import('../../features/translation/TranslationPage')) +const CurrencyExchangePage = lazy(() => import('../../features/currency-exchange/CurrencyExchangePage')) const LabsPage = lazy(() => import('../../features/labs/pages/LabsPage')) const SupportPage = lazy(() => import('../../features/support/SupportPage')) const HabitTrackerPage = lazy(() => import('../../features/habits/pages/HabitTrackerPage')) @@ -65,6 +66,7 @@ const AppRoutes = () => { } /> } /> }>} /> + }>} /> }>} /> }>} /> } /> diff --git a/apps/web/src/app/routes/routes.ts b/apps/web/src/app/routes/routes.ts index d1a9690..695e28e 100644 --- a/apps/web/src/app/routes/routes.ts +++ b/apps/web/src/app/routes/routes.ts @@ -21,6 +21,7 @@ export const ROUTES = { ASTROLOGY: '/astrology', TAROT: '/tarot', TRANSLATION: '/translation', + CURRENCY_EXCHANGE: '/currency-exchange', SUPPORT: '/support', MEMBERSHIP: '/membership', PREMIUM: '/premium', diff --git a/apps/web/src/data/models/types.ts b/apps/web/src/data/models/types.ts index 3a15e2e..dd50cd2 100644 --- a/apps/web/src/data/models/types.ts +++ b/apps/web/src/data/models/types.ts @@ -727,7 +727,7 @@ export type TaskTrashEntry = { deletedAt: number } -export type FeatureKey = 'ai-digest' | 'automation' | 'bazi' | 'astrology' | 'tarot' | 'habit-tracker' | 'project-workspace' | 'translation' +export type FeatureKey = 'ai-digest' | 'automation' | 'bazi' | 'astrology' | 'tarot' | 'habit-tracker' | 'project-workspace' | 'translation' | 'currency-exchange' export type FeatureInstallState = 'installed' | 'removed' export type FeatureInstallation = BaseEntity & { diff --git a/apps/web/src/features/currency-exchange/CurrencyExchangePage.test.tsx b/apps/web/src/features/currency-exchange/CurrencyExchangePage.test.tsx new file mode 100644 index 0000000..b3afb71 --- /dev/null +++ b/apps/web/src/features/currency-exchange/CurrencyExchangePage.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CurrencyExchangePage from './CurrencyExchangePage' + +let installed = true +vi.mock('../labs/LabsContext', () => ({ useLabs: () => ({ ready: true, catalog: [{ featureKey: 'currency-exchange', state: installed ? 'installed' : 'available' }] }) })) +vi.mock('../../shared/i18n/useI18n', () => ({ useI18n: () => ({ language: 'zh' }) })) +vi.mock('recharts', () => ({ ResponsiveContainer: () =>
chart
, LineChart: () => null, Line: () => null, CartesianGrid: () => null, Tooltip: () => null, XAxis: () => null, YAxis: () => null })) +const mount = () => render(} />Labs destination} />) +afterEach(() => { cleanup(); vi.unstubAllGlobals(); installed = true }) +describe('currency exchange', () => { + it('requires installation', () => { installed = false; mount(); expect(screen.getByText('Labs destination')).toBeInTheDocument() }) + it('converts amounts and fetches selected pair and range', async () => { + const fetchMock = vi.fn(async (input: string) => ({ ok: true, json: async () => input.endsWith('/currencies') ? { USD: 'US Dollar', CNY: 'Chinese Yuan', EUR: 'Euro', GBP: 'Pound', JPY: 'Yen', CAD: 'Canadian Dollar' } : input.includes('/latest') ? { date: '2026-10-27', rates: { CNY: 7, EUR: 0.9, GBP: 0.8, JPY: 150, CAD: 1.4 } } : { rates: { '2026-10-01': { CNY: 7, EUR: 0.9 } } } })) + vi.stubGlobal('fetch', fetchMock); mount() + expect(await screen.findByText('700')).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'CNY · 人民币' })).toBeInTheDocument() + expect(screen.getByText('欧元')).toBeInTheDocument() + await userEvent.clear(screen.getByLabelText('基准金额与货币')) + await userEvent.type(screen.getByLabelText('基准金额与货币'), '2') + expect(screen.getByText('14')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /^EUR欧元/ })) + await userEvent.click(screen.getByRole('button', { name: '1年' })) + await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => url.includes('symbols=EUR'))).toBe(true)) + expect(screen.getByText(/数据日期: 2026-10-27/)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/features/currency-exchange/CurrencyExchangePage.tsx b/apps/web/src/features/currency-exchange/CurrencyExchangePage.tsx new file mode 100644 index 0000000..fbd0441 --- /dev/null +++ b/apps/web/src/features/currency-exchange/CurrencyExchangePage.tsx @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { ArrowLeftRight, Plus, X } from 'lucide-react' +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import { useLabs } from '../labs/LabsContext' +import { useI18n } from '../../shared/i18n/useI18n' +import { ROUTES } from '../../app/routes/routes' +import BrandLoader from '../../shared/ui/loading/BrandLoader' +import './currency-exchange.css' + +type RateResponse = { date: string; rates: Record } +type Period = '1w' | '1m' | '3m' | '1y' | '5y' +type HistoryResponse = { rates: Record> } +const API = 'https://api.frankfurter.dev/v1' +const periods: Period[] = ['1w', '1m', '3m', '1y', '5y'] +const defaultTargets = ['CNY', 'EUR', 'GBP', 'JPY', 'CAD'] +const dateString = (d: Date) => d.toISOString().slice(0, 10) +const startDate = (period: Period) => { + const d = new Date() + if (period === '1w') d.setUTCDate(d.getUTCDate() - 7) + else if (period === '1m') d.setUTCMonth(d.getUTCMonth() - 1) + else if (period === '3m') d.setUTCMonth(d.getUTCMonth() - 3) + else d.setUTCFullYear(d.getUTCFullYear() - (period === '1y' ? 1 : 5)) + return dateString(d) +} +const readJson = async (url: string, signal: AbortSignal): Promise => { + const response = await fetch(url, { signal }) + if (!response.ok) throw new Error(`Exchange rate request failed: ${response.status}`) + return response.json() as Promise +} +const format = (number: number, digits = 4) => new Intl.NumberFormat(undefined, { maximumFractionDigits: digits }).format(number) + +export default function CurrencyExchangePage() { + const { ready, catalog } = useLabs() + const { language } = useI18n() + const zh = language === 'zh' + const currencyNames = useMemo(() => new Intl.DisplayNames([zh ? 'zh-CN' : 'en'], { type: 'currency' }), [zh]) + const [currencies, setCurrencies] = useState>({}) + const [base, setBase] = useState('USD') + const [amount, setAmount] = useState('100') + const [targets, setTargets] = useState(defaultTargets) + const [selected, setSelected] = useState('CNY') + const [add, setAdd] = useState('') + const [period, setPeriod] = useState('1m') + const [rates, setRates] = useState(null) + const [history, setHistory] = useState(null) + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [historyStatus, setHistoryStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [retry, setRetry] = useState(0) + const installed = catalog.some(item => item.featureKey === 'currency-exchange' && item.state === 'installed') + + useEffect(() => { + if (!installed) return + const controller = new AbortController() + setStatus('loading'); setRates(null) + Promise.all([ + readJson>(`${API}/currencies`, controller.signal), + readJson(`${API}/latest?base=${encodeURIComponent(base)}`, controller.signal), + ]).then(([list, latest]) => { setCurrencies(list); setRates(latest); setStatus('ready') }).catch(() => { if (!controller.signal.aborted) setStatus('error') }) + return () => controller.abort() + }, [base, installed, retry]) + useEffect(() => { + if (!installed || !selected || selected === base) return + const controller = new AbortController() + setHistoryStatus('loading'); setHistory(null) + readJson(`${API}/${startDate(period)}..${dateString(new Date())}?base=${encodeURIComponent(base)}&symbols=${encodeURIComponent(selected)}`, controller.signal) + .then(data => { setHistory(data); setHistoryStatus('ready') }).catch(() => { if (!controller.signal.aborted) setHistoryStatus('error') }) + return () => controller.abort() + }, [base, selected, period, installed, retry]) + const chart = useMemo(() => Object.entries(history?.rates ?? {}).sort(([a], [b]) => a.localeCompare(b)).flatMap(([date, values]) => typeof values[selected] === 'number' ? [{ date, rate: values[selected] }] : []), [history, selected]) + const currencyName = (code: string) => currencyNames.of(code) ?? currencies[code] ?? code + const available = Object.keys(currencies).filter(code => code !== base && !targets.includes(code)).sort() + const current = rates?.rates[selected] + const labels: Record = zh ? { '1w': '1周', '1m': '1个月', '3m': '3个月', '1y': '1年', '5y': '5年' } : { '1w': '1W', '1m': '1M', '3m': '3M', '1y': '1Y', '5y': '5Y' } + const changeBase = (next: string) => { + if (next === base) return + const nextTargets = [...new Set([base, ...targets.filter(code => code !== next)])] + setBase(next); setTargets(nextTargets); if (selected === next) setSelected(base) + } + if (!ready) return + if (!installed) return + return
+

{zh ? '汇率转换' : 'Currency exchange'}

{zh ? '换算多种货币,查看历史走势。' : 'Convert currencies and explore historical trends.'}

+

{zh ? '货币换算' : 'Convert currencies'}

+ +
setAmount(e.target.value)} />
+

{zh ? '换算结果' : 'Converted amounts'}

+
{targets.map(code =>
)}
+
+

{status === 'loading' ? (zh ? '正在获取汇率…' : 'Loading rates…') : status === 'error' ? (zh ? '汇率获取失败,请检查网络后重试。' : 'Could not load rates. Check your connection and retry.') : `${zh ? '数据日期' : 'Rate date'}: ${rates?.date}`} {status === 'error' && }

+

{base} / {selected} {zh ? '汇率走势' : 'exchange rate'}

+

1 {base} = {current ? format(current) : '—'} {selected}

+
{periods.map(p => )}
+ {historyStatus === 'loading' ?
{zh ? '正在获取历史数据…' : 'Loading historical data…'}
: historyStatus === 'error' ?
{zh ? '历史数据获取失败。' : 'Could not load historical data.'}
: chart.length === 0 ?
{zh ? '该时间范围暂无数据。' : 'No data for this period.'}
:
typeof v === 'number' ? format(v, 6) : String(v)} />
} +

{zh ? '每日参考汇率,可能与银行或支付平台实际成交汇率不同。数据来源:' : 'Daily reference rates may differ from your bank or payment provider. Source: '}Frankfurter

+
+
+} diff --git a/apps/web/src/features/currency-exchange/currency-exchange.css b/apps/web/src/features/currency-exchange/currency-exchange.css new file mode 100644 index 0000000..13a5106 --- /dev/null +++ b/apps/web/src/features/currency-exchange/currency-exchange.css @@ -0,0 +1 @@ +.currency-page{display:grid;align-content:start;gap:16px}.currency-header,.currency-panel{background:var(--paper-raised);border:1px solid var(--rule);border-radius:var(--radius-lg);box-shadow:var(--elev-1)}.currency-header{padding:28px 32px}.currency-header h1{margin:0}.currency-header p{color:var(--text-secondary);margin:8px 0 0}.currency-layout{display:grid;grid-template-columns:minmax(290px, .85fr) minmax(400px,1.45fr);gap:14px}.currency-panel{padding:24px;min-width:0}.currency-panel h2{font-size:var(--fs-subhead);margin:0 0 24px}.currency-panel h3,.currency-label{display:block;font-size:var(--fs-ui);font-weight:600;margin:26px 0 10px}.currency-input{display:flex;border:1px solid var(--rule);border-radius:var(--radius-md);overflow:hidden}.currency-input input,.currency-input select,.currency-add select{background:var(--paper-raised);color:var(--text-primary);border:0;min-width:0;padding:12px}.currency-input input{width:48%;font-size:22px}.currency-input select{flex:1;border-left:1px solid var(--rule)}.currency-rows{display:grid;gap:8px}.currency-row{display:flex;align-items:center;border:1px solid var(--rule);background:var(--paper-sunken);border-radius:var(--radius-md)}.currency-row.is-selected{background:color-mix(in srgb,var(--accent) 12%,var(--paper-raised));border-color:color-mix(in srgb,var(--accent) 35%,var(--rule))}.currency-row-select{display:flex;align-items:center;gap:10px;text-align:left;min-width:0;flex:1;padding:14px;border:0;background:transparent;color:var(--text-primary);cursor:pointer}.currency-row-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary)}.currency-row-select b{margin-left:auto;font-variant-numeric:tabular-nums}.currency-remove,.currency-trend-head button,.currency-add button,.currency-periods button{display:inline-flex;align-items:center;gap:6px;border:0;background:transparent;color:var(--text-secondary);cursor:pointer;padding:10px}.currency-remove:disabled,.currency-add button:disabled{opacity:.4;cursor:default}.currency-add{display:flex;gap:8px;margin-top:12px}.currency-add select{border:1px solid var(--rule);border-radius:var(--radius-md);flex:1}.currency-add button{border:1px solid var(--rule);border-radius:var(--radius-md)}.currency-trend-head{display:flex;justify-content:space-between;align-items:start;gap:8px}.currency-trend-head h2{margin-bottom:0}.currency-rate{font-size:clamp(24px,3vw,42px);font-weight:600;margin:24px 0}.currency-rate strong{color:var(--accent)}.currency-periods{display:flex;gap:4px;flex-wrap:wrap;margin:20px 0}.currency-periods button{border-radius:var(--radius-md);background:var(--paper-sunken)}.currency-periods button.is-selected{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,var(--paper-raised));font-weight:600}.currency-chart,.currency-placeholder{height:280px}.currency-placeholder{display:grid;place-content:center;color:var(--text-secondary)}.currency-note{font-size:var(--fs-meta);color:var(--text-secondary);line-height:1.6;margin:24px 0 0}.currency-note a{color:var(--accent)}@media(max-width:850px){.currency-layout{grid-template-columns:1fr}.currency-header{padding:22px}.currency-panel{padding:20px}} diff --git a/apps/web/src/features/labs/labsApi.test.ts b/apps/web/src/features/labs/labsApi.test.ts index 3fb5c13..8bb3f62 100644 --- a/apps/web/src/features/labs/labsApi.test.ts +++ b/apps/web/src/features/labs/labsApi.test.ts @@ -58,6 +58,17 @@ describe('labsApi', () => { expect((await translation())?.state).toBe('installed') }) + it('supports installing, removing and restoring currency exchange', async () => { + const feature = async () => (await getFeatureCatalog()).find(item => item.featureKey === 'currency-exchange') + expect(await feature()).toMatchObject({ state: 'available', comingSoon: false }) + await installFeature('currency-exchange') + expect((await feature())?.state).toBe('installed') + await removeFeature('currency-exchange') + expect((await feature())?.state).toBe('removed') + await restoreFeature('currency-exchange') + expect((await feature())?.state).toBe('installed') + }) + it('ignores legacy premium hints', async () => { await ensureLabsSeed() localStorage.setItem('auth', JSON.stringify({ plan: 'premium' })) diff --git a/apps/web/src/features/labs/labsApi.ts b/apps/web/src/features/labs/labsApi.ts index e811426..22999da 100644 --- a/apps/web/src/features/labs/labsApi.ts +++ b/apps/web/src/features/labs/labsApi.ts @@ -32,6 +32,7 @@ export type UserSubscriptionRecord = { } const FEATURE_META: FeatureMeta[] = [ + { featureKey: 'currency-exchange', title: 'Currency Exchange', description: 'Convert currencies and explore historical exchange rates.', premiumOnly: false, comingSoon: false }, { featureKey: 'bazi', title: 'BaZi', description: 'Four pillars, luck cycles and transparent rule-based readings.', premiumOnly: false, comingSoon: false }, { featureKey: 'astrology', title: 'Astrology', description: 'A personal natal chart with planetary positions and current transits.', premiumOnly: false, comingSoon: false }, { featureKey: 'tarot', title: 'Tarot', description: 'Daily cards, three-card spreads and a private reflection journal.', premiumOnly: false, comingSoon: false }, diff --git a/apps/web/src/features/labs/labsI18n.ts b/apps/web/src/features/labs/labsI18n.ts index 45a769e..912bb9f 100644 --- a/apps/web/src/features/labs/labsI18n.ts +++ b/apps/web/src/features/labs/labsI18n.ts @@ -65,6 +65,7 @@ const messages: Record = { bazi: 'BaZi', astrology: 'Astrology', tarot: 'Tarot', 'habit-tracker': 'Habit Tracker', translation: 'Translate', + 'currency-exchange': 'Currency Exchange', 'project-workspace': 'Project', 'ai-digest': 'AI Digest', automation: 'Automation Flows', @@ -75,6 +76,7 @@ const messages: Record = { tarot: 'Choose cards by hand, explore questions and keep complete readings.', 'habit-tracker': 'Identity-first habit dashboard with streaks, progress ring, and heatmap.', translation: 'Free online and offline translation, business expressions and language packs downloaded as needed.', + 'currency-exchange': 'Convert currencies and explore historical exchange rates.', 'project-workspace': 'Dedicated workspace for complex projects with clear goals and timelines.', 'ai-digest': 'Weekly summarization digest for your subscriptions.', automation: 'Composable productivity automations and triggers.', @@ -135,6 +137,7 @@ const messages: Record = { bazi: '八字 · 四时', astrology: '星座 · 星图', tarot: '塔罗 · 一刻', 'habit-tracker': '习惯追踪', translation: '翻译', + 'currency-exchange': '汇率转换', 'project-workspace': '项目', 'ai-digest': 'AI Digest', automation: '自动化流程', @@ -145,6 +148,7 @@ const messages: Record = { tarot: '每日一牌与主题问事,亲手选牌,保存解读与心得。', 'habit-tracker': '以身份认同为核心的习惯仪表盘,带有连续记录、进度环和热力图。', translation: '免费在线与离线翻译,支持商务表达,离线语言包按需下载。', + 'currency-exchange': '换算多种货币,查看历史汇率走势。', 'project-workspace': '为复杂项目打造的专属工作区,目标清晰,时间线明确。', 'ai-digest': '每周自动汇总你的订阅内容摘要。', automation: '可组合的生产力自动化流程与触发器。', diff --git a/apps/web/src/features/labs/pages/LabsPage.tsx b/apps/web/src/features/labs/pages/LabsPage.tsx index 160dd79..d5af523 100644 --- a/apps/web/src/features/labs/pages/LabsPage.tsx +++ b/apps/web/src/features/labs/pages/LabsPage.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom' import { useState } from 'react' -import { Languages, Columns3, Orbit, Layers, Archive, LayoutGrid, Sparkles, Brain, Zap, Target, RotateCcw, PanelsTopLeft } from 'lucide-react' +import { ArrowLeftRight, Languages, Columns3, Orbit, Layers, Archive, LayoutGrid, Sparkles, Brain, Zap, Target, RotateCcw, PanelsTopLeft } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' @@ -15,6 +15,7 @@ const SYMBOLIC_ROUTES: Record = { bazi: ROUTES.BAZI, astrology: const FEATURE_ICONS: Record = { bazi: Columns3, astrology: Orbit, tarot: Layers, translation: Languages, + 'currency-exchange': ArrowLeftRight, 'ai-digest': Brain, 'automation': Zap, 'habit-tracker': Target, @@ -139,7 +140,9 @@ const LabsPage = () => { {i18n.featureDescriptions[feature.featureKey] ?? feature.description}
- {feature.featureKey === 'translation' ? ( + {feature.featureKey === 'currency-exchange' ? ( + + ) : feature.featureKey === 'translation' ? (