From 17637054be52893bea6631f9b09185c742227007 Mon Sep 17 00:00:00 2001 From: kazelise Date: Sun, 13 Oct 2024 12:56:41 +0800 Subject: [PATCH] =?UTF-8?q?Enhance=20UI=20by=20adding=20rounded=20corners?= =?UTF-8?q?=20to=20CurrencySelector=20and=20related=20components=20Relevan?= =?UTF-8?q?t=20Issue:=20Fixes=20#10=20=E2=80=93=20Add=20support=20for=20mu?= =?UTF-8?q?ltiple=20currencies.=20Calculate=20exchange=20rate=20by=20using?= =?UTF-8?q?=20Frankfurter=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 73 ++++++++ src/app/page.tsx | 278 +++++++++++++++++----------- src/components/CurrencySelector.tsx | 40 ++++ src/components/SubscriptionItem.tsx | 56 +++--- src/lib/subscriptionStore.ts | 30 ++- src/lib/utils.ts | 48 +++++ 7 files changed, 388 insertions(+), 138 deletions(-) create mode 100644 src/components/CurrencySelector.tsx diff --git a/package.json b/package.json index 3501989..4e46d2c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@t3-oss/env-nextjs": "^0.10.1", "@tabler/icons-react": "^3.19.0", "@types/canvas-confetti": "^1.6.4", + "axios": "^1.7.7", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a6338c..b79289f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: '@types/canvas-confetti': specifier: ^1.6.4 version: 1.6.4 + axios: + specifier: ^1.7.7 + version: 1.7.7 canvas-confetti: specifier: ^1.9.3 version: 1.9.3 @@ -1805,6 +1808,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1813,6 +1819,9 @@ packages: resolution: {integrity: sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==} engines: {node: '>=4'} + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1912,6 +1921,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2038,6 +2051,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2401,6 +2418,15 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -2408,6 +2434,10 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -2905,6 +2935,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3130,6 +3168,9 @@ packages: property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5158,12 +5199,22 @@ snapshots: ast-types-flow@0.0.8: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 axe-core@4.10.0: {} + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -5260,6 +5311,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@4.1.1: {} @@ -5387,6 +5442,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.0.2: {} @@ -5869,6 +5926,8 @@ snapshots: flatted@3.3.1: {} + follow-redirects@1.15.9: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -5878,6 +5937,12 @@ snapshots: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -6514,6 +6579,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6739,6 +6810,8 @@ snapshots: property-information@6.5.0: {} + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} qss@3.0.0: {} diff --git a/src/app/page.tsx b/src/app/page.tsx index 125e1c8..25641e3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ 'use client' -import SubscriptionItem from '@/components/SubscriptionItem' -import { Button } from '@/components/ui/button' +import SubscriptionItem from '@/components/SubscriptionItem'; +import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -9,148 +9,204 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from '@/components/ui/dialog' -import { FloatingDock } from '@/components/ui/floating-dock' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { LinkPreview } from '@/components/ui/link-preview' -import { PlusCircle } from 'lucide-react' -import Image from 'next/image' -import { useEffect, useState } from 'react' -import InstructionsPopup from '~/components/InstructionsPopup' -import MadeWithKodu from '~/components/MadeWithKodu' -import { env } from '~/env' -import { useSubscriptionStore } from '~/lib/subscriptionStore' +} from '@/components/ui/dialog'; +import { FloatingDock } from '@/components/ui/floating-dock'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { LinkPreview } from '@/components/ui/link-preview'; +import { PlusCircle } from 'lucide-react'; +import Image from 'next/image'; +import { useEffect, useState } from 'react'; +import InstructionsPopup from '@/components/InstructionsPopup'; +import MadeWithKodu from '@/components/MadeWithKodu'; +import CurrencySelector from '@/components/CurrencySelector'; +import { env } from '~/env'; +import { useSubscriptionStore } from '~/lib/subscriptionStore'; +import { convertPrice, formatPrice } from '~/lib/utils'; interface Subscription { - id: number - name: string - url: string - price: number - icon: string + id: number; + name: string; + url: string; + price: number; + currency: string; + icon: string; } export default function Component() { - const { subscriptions, addSubscription, removeSubscription, editSubscription } = useSubscriptionStore() - const [mounted, setMounted] = useState(false) - const [isOpen, setIsOpen] = useState(false) - const [editingSubscription, setEditingSubscription] = useState(null) + const { subscriptions, addSubscription, removeSubscription, editSubscription, globalCurrency, setGlobalCurrency, fetchExchangeRates, exchangeRates } = useSubscriptionStore(); + const [mounted, setMounted] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [editingSubscription, setEditingSubscription] = useState(null); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [price, setPrice] = useState(''); + const [currency, setCurrency] = useState('USD'); + const [loadingRates, setLoadingRates] = useState(true); useEffect(() => { - setMounted(true) - }, []) + setMounted(true); + const fetchRates = async () => { + await fetchExchangeRates(); + setLoadingRates(false); + }; + fetchRates(); + }, [fetchExchangeRates]); - const totalMonthly = subscriptions.reduce((sum, sub) => sum + sub.price, 0) + useEffect(() => { + if (editingSubscription) { + setName(editingSubscription.name); + setUrl(editingSubscription.url); + setPrice(editingSubscription.price.toString()); + setCurrency(editingSubscription.currency); + } else { + setName(''); + setUrl(''); + setPrice(''); + setCurrency('USD'); + } + }, [editingSubscription]); + + const totalMonthlyBase = subscriptions.reduce((sum, sub) => sum + convertPrice(sub.price, sub.currency, globalCurrency, exchangeRates), 0); + const formattedTotalMonthly = formatPrice(totalMonthlyBase, globalCurrency); const handleSubmit = (event: React.FormEvent) => { - event.preventDefault() - const formData = new FormData(event.currentTarget) - const name = formData.get('name') as string - const url = formData.get('url') as string - const price = Number.parseFloat(formData.get('price') as string) - const icon = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=64` + event.preventDefault(); - if (name && url && price) { + const icon = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=64`; + + if (name && url && !isNaN(Number(price)) && currency) { if (editingSubscription) { - editSubscription(editingSubscription.id, { name, url, price, icon }) + editSubscription(editingSubscription.id, { name, url, price: Number(price), currency, icon }); } else { - addSubscription({ name, url, price, icon }) + addSubscription({ name, url, price: Number(price), currency, icon }); } - setIsOpen(false) - setEditingSubscription(null) - ;(event.target as HTMLFormElement).reset() + setIsOpen(false); + setEditingSubscription(null); } - } + }; const handleEdit = (subscription: Subscription) => { - setEditingSubscription(subscription) - setIsOpen(true) - } + setEditingSubscription(subscription); + setIsOpen(true); + }; const dockItems = subscriptions.map((sub) => ({ title: sub.name, icon: {`${sub.name}, href: sub.url, - })) + })); if (!mounted) { - return null // Return null on initial render to avoid hydration mismatch + return null; } + const availableCurrencies = Object.keys(exchangeRates).sort(); + return (

Monthly Subscriptions Tracker

- - - - - - - - {editingSubscription ? 'Edit Subscription' : 'Add New Subscription'} - - - {editingSubscription - ? 'Edit the details of your subscription.' - : 'Enter the details for your new subscription.'} - - - -
-
- - -
-
- - -
-
- - -
- -
-
-
+ + + + + {editingSubscription ? 'Edit Subscription' : 'Add New Subscription'} + + + {editingSubscription ? 'Edit the details of your subscription.' : 'Enter the details for your new subscription.'} + + + +
+
+ + setName(e.target.value)} + /> +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setPrice(e.target.value)} + /> +
+
+ + {loadingRates ? ( +
Loading...
+ ) : ( + setCurrency(e.target.value)} + /> + )} +
+ +
+
+ +
-
- Total Monthly: ${totalMonthly.toFixed(2)} +
+ Total Monthly: {formattedTotalMonthly}
{subscriptions.map((subscription) => ( @@ -167,5 +223,5 @@ export default function Component() { mobileClassName="fixed bottom-4 right-4" />
- ) + ); } diff --git a/src/components/CurrencySelector.tsx b/src/components/CurrencySelector.tsx new file mode 100644 index 0000000..1f4d33b --- /dev/null +++ b/src/components/CurrencySelector.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +interface CurrencySelectorProps { + id: string; + name: string; + currencies: string[]; + required?: boolean; + value: string; + onChange: (event: React.ChangeEvent) => void; + className?: string; +} + +const CurrencySelector: React.FC = ({ + id, + name, + currencies, + required, + value, + onChange, + className, +}) => { + return ( + + ); +}; + +export default CurrencySelector; diff --git a/src/components/SubscriptionItem.tsx b/src/components/SubscriptionItem.tsx index 6e08495..5c2ca3b 100644 --- a/src/components/SubscriptionItem.tsx +++ b/src/components/SubscriptionItem.tsx @@ -1,21 +1,23 @@ -'use client' -import { Edit2, Trash2 } from 'lucide-react' -import Image from 'next/image' -import Link from 'next/link' -import type React from 'react' +'use client'; +import { Edit2, Trash2 } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import React from 'react'; +import { formatPrice } from '~/lib/utils'; // 从utils导入formatPrice函数 interface Subscription { - id: number - name: string - url: string - price: number - icon: string + id: number; + name: string; + url: string; + price: number; + currency: string; + icon: string; } interface SubscriptionItemProps { - subscription: Subscription - onRemove: (id: number) => void - onEdit: (subscription: Subscription) => void + subscription: Subscription; + onRemove: (id: number) => void; + onEdit: (subscription: Subscription) => void; } const SubscriptionItem: React.FC = ({ subscription, onRemove, onEdit }) => { @@ -32,16 +34,22 @@ const SubscriptionItem: React.FC = ({ subscription, onRem />

{subscription.name}

-

${subscription.price.toFixed(2)}/mo

+

+ {formatPrice(subscription.price, subscription.currency)}/mo +

- Visit site + + + Visit site + +
- ) -} + ); +}; -export default SubscriptionItem +export default SubscriptionItem; diff --git a/src/lib/subscriptionStore.ts b/src/lib/subscriptionStore.ts index 7860da3..06fe363 100644 --- a/src/lib/subscriptionStore.ts +++ b/src/lib/subscriptionStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import axios from 'axios'; import { env } from '~/env'; interface Subscription { @@ -7,11 +8,16 @@ interface Subscription { name: string; url: string; price: number; + currency: string; icon: string; } interface SubscriptionStore { subscriptions: Subscription[]; + exchangeRates: Record; + globalCurrency: string; + setGlobalCurrency: (currency: string) => void; + fetchExchangeRates: () => Promise; addSubscription: (subscription: Omit) => void; removeSubscription: (id: number) => void; editSubscription: (id: number, updatedSubscription: Omit) => void; @@ -23,6 +29,7 @@ const defaultSubscriptions: Subscription[] = [ name: 'Netflix', url: 'https://www.netflix.com', price: 15.99, + currency: 'USD', icon: 'https://www.google.com/s2/favicons?domain=netflix.com', }, { @@ -30,6 +37,7 @@ const defaultSubscriptions: Subscription[] = [ name: 'Google One', url: 'https://one.google.com', price: 1.99, + currency: 'USD', icon: 'https://www.google.com/s2/favicons?domain=google.com', }, { @@ -37,6 +45,7 @@ const defaultSubscriptions: Subscription[] = [ name: 'Amazon Prime', url: 'https://www.amazon.com/prime', price: 14.99, + currency: 'USD', icon: 'https://www.google.com/s2/favicons?domain=amazon.com', }, { @@ -44,6 +53,7 @@ const defaultSubscriptions: Subscription[] = [ name: 'Spotify', url: 'https://www.spotify.com', price: 9.99, + currency: 'USD', icon: 'https://www.google.com/s2/favicons?domain=spotify.com', }, { @@ -51,13 +61,13 @@ const defaultSubscriptions: Subscription[] = [ name: 'YouTube Premium', url: 'https://onlyfans.com/', price: 69.99, + currency: 'USD', icon: 'https://www.google.com/s2/favicons?domain=onlyfans.com', }, ]; const getStorage = () => { if (typeof window === 'undefined') { - // Return a dummy storage for SSR return { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), @@ -75,7 +85,6 @@ const getStorage = () => { const response = await fetch(`/api/kv/${name}`); if (response.ok) { const data = await response.json(); - // If the value is an empty array, return the default subscriptions if (Array.isArray(data.value) && data.value.length === 0) { return JSON.stringify({ subscriptions: defaultSubscriptions }); } @@ -116,6 +125,21 @@ export const useSubscriptionStore = create()( persist( (set) => ({ subscriptions: defaultSubscriptions, + exchangeRates: { USD: 1 }, + globalCurrency: 'USD', + setGlobalCurrency: (currency: string) => set({ globalCurrency: currency }), + fetchExchangeRates: async () => { + try { + const response = await axios.get('https://api.frankfurter.app/latest?from=USD'); + const rates = response.data.rates; + if (!rates.USD) { + rates.USD = 1; + } + set({ exchangeRates: rates }); + } catch (error) { + console.error('Error fetching exchange rates:', error); + } + }, addSubscription: (newSubscription) => set((state) => ({ subscriptions: [...state.subscriptions, { ...newSubscription, id: Date.now() }], @@ -137,4 +161,4 @@ export const useSubscriptionStore = create()( skipHydration: false, } ) -); +); \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fed2fe9..be4ff71 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,51 @@ import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const defaultRates = { USD: 1 }; + +export async function getExchangeRates(): Promise> { + const url = 'https://api.frankfurter.app/latest?base=USD'; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.rates; + } catch (error) { + console.error('Error fetching exchange rates:', error); + return {}; + } +} + +export function convertPrice(price: number, fromCurrency: string, toCurrency: string, rates: Record): number { + if (fromCurrency === toCurrency) return price; + + const fromRate = rates[fromCurrency]; + const toRate = rates[toCurrency]; + + if (fromRate === undefined) { + console.error(`Undefined exchange rate for currency: ${fromCurrency}`); + return price; + } + + if (toRate === undefined) { + console.error(`Undefined exchange rate for currency: ${toCurrency}`); + return price; + } + + const rateInUSD = price / fromRate; + return rateInUSD * toRate; +} + +// 价格格式化函数 +export function formatPrice(price: number, currency: string): string { + const formattedPrice = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(price); + return `${formattedPrice} ${currency}`; +}