feat(33-05): market/currency selector, dual price format, conversion toggle
- Add formatDualPrice() with ~prefix for approximate conversions (D-14) - Evolve useCurrency() to return CurrencyContext with currency, market, showConversions - Create useExchangeRates hook + convertClientPrice utility - Redesign settings: Market & Currency selector, Show Converted Prices toggle - Add locale-based auto-suggestion banner for first-time currency selection (D-13) - Update useFormatters to destructure from new CurrencyContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,42 @@
|
|||||||
import type { Currency } from "../lib/formatters";
|
import type { Currency } from "../lib/formatters";
|
||||||
import { useSetting } from "./useSettings";
|
import { useSetting } from "./useSettings";
|
||||||
|
|
||||||
const VALID_CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY", "CAD", "AUD"];
|
const VALID_CURRENCIES: Currency[] = [
|
||||||
|
"USD",
|
||||||
|
"EUR",
|
||||||
|
"GBP",
|
||||||
|
"JPY",
|
||||||
|
"CAD",
|
||||||
|
"AUD",
|
||||||
|
];
|
||||||
|
|
||||||
export function useCurrency(): Currency {
|
const CURRENCY_MARKET_MAP: Record<string, string> = {
|
||||||
const { data } = useSetting("currency");
|
EUR: "EU",
|
||||||
if (data && VALID_CURRENCIES.includes(data as Currency)) {
|
USD: "US",
|
||||||
return data as Currency;
|
GBP: "UK",
|
||||||
}
|
JPY: "JP",
|
||||||
return "USD";
|
CAD: "CA",
|
||||||
|
AUD: "AU",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CurrencyContext {
|
||||||
|
currency: Currency;
|
||||||
|
market: string;
|
||||||
|
showConversions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrency(): CurrencyContext {
|
||||||
|
const { data: currencyData } = useSetting("currency");
|
||||||
|
const { data: showConversionsData } = useSetting("showConversions");
|
||||||
|
|
||||||
|
const currency: Currency =
|
||||||
|
currencyData && VALID_CURRENCIES.includes(currencyData as Currency)
|
||||||
|
? (currencyData as Currency)
|
||||||
|
: "USD";
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
market: CURRENCY_MARKET_MAP[currency] ?? currency,
|
||||||
|
showConversions: showConversionsData === "true",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/client/hooks/useExchangeRates.ts
Normal file
34
src/client/hooks/useExchangeRates.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
|
||||||
|
interface ExchangeRates {
|
||||||
|
base: string;
|
||||||
|
date: string;
|
||||||
|
rates: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExchangeRates() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["exchange-rates"],
|
||||||
|
queryFn: () => apiGet<ExchangeRates>("/api/exchange-rates"),
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour client-side stale time
|
||||||
|
gcTime: 1000 * 60 * 60 * 24, // 24 hour garbage collection
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert price in cents from one currency to another using provided rates.
|
||||||
|
* All conversions go through EUR as the base currency.
|
||||||
|
*/
|
||||||
|
export function convertClientPrice(
|
||||||
|
cents: number,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
rates: Record<string, number>,
|
||||||
|
): number {
|
||||||
|
if (from === to) return cents;
|
||||||
|
const fromRate = rates[from] ?? 1;
|
||||||
|
const toRate = rates[to] ?? 1;
|
||||||
|
return Math.round((cents / fromRate) * toRate);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useWeightUnit } from "./useWeightUnit";
|
|||||||
|
|
||||||
export function useFormatters() {
|
export function useFormatters() {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const currency = useCurrency();
|
const { currency } = useCurrency();
|
||||||
return {
|
return {
|
||||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||||
price: (cents: number | null) => formatPrice(cents, currency),
|
price: (cents: number | null) => formatPrice(cents, currency),
|
||||||
|
|||||||
@@ -43,3 +43,23 @@ export function formatPrice(
|
|||||||
}
|
}
|
||||||
return `${symbol}${(cents / 100).toFixed(2)}`;
|
return `${symbol}${(cents / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DualPriceOptions {
|
||||||
|
sourceCents: number;
|
||||||
|
sourceCurrency: Currency;
|
||||||
|
targetCurrency: Currency;
|
||||||
|
convertedCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price with dual display: source price + converted in parentheses.
|
||||||
|
* Per D-14: "€2,000 (~£1,720)" — source prominent, converted approximate.
|
||||||
|
*/
|
||||||
|
export function formatDualPrice(options: DualPriceOptions): {
|
||||||
|
source: string;
|
||||||
|
converted: string;
|
||||||
|
} {
|
||||||
|
const source = formatPrice(options.sourceCents, options.sourceCurrency);
|
||||||
|
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency)}`;
|
||||||
|
return { source, converted };
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,11 +201,42 @@ function ImportExportSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCALE_CURRENCY_MAP: Record<string, Currency> = {
|
||||||
|
de: "EUR",
|
||||||
|
fr: "EUR",
|
||||||
|
es: "EUR",
|
||||||
|
it: "EUR",
|
||||||
|
nl: "EUR",
|
||||||
|
pt: "EUR",
|
||||||
|
en: "USD",
|
||||||
|
ja: "JPY",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSuggestedCurrency(): Currency | null {
|
||||||
|
try {
|
||||||
|
const lang = navigator.language;
|
||||||
|
// Check full locale first (e.g., en-GB → GBP)
|
||||||
|
if (lang.startsWith("en-GB")) return "GBP";
|
||||||
|
if (lang.startsWith("en-AU")) return "AUD";
|
||||||
|
if (lang.startsWith("en-CA") || lang.startsWith("fr-CA")) return "CAD";
|
||||||
|
// Fall back to language prefix
|
||||||
|
const prefix = lang.split("-")[0];
|
||||||
|
return LOCALE_CURRENCY_MAP[prefix] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const currency = useCurrency();
|
const { currency, showConversions } = useCurrency();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
|
const [suggestionDismissed, setSuggestionDismissed] = useState(false);
|
||||||
|
|
||||||
|
const suggestedCurrency = getSuggestedCurrency();
|
||||||
|
const showSuggestion =
|
||||||
|
!suggestionDismissed && suggestedCurrency && suggestedCurrency !== currency;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
@@ -219,6 +250,32 @@ function SettingsPage() {
|
|||||||
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSuggestion && (
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-blue-700">
|
||||||
|
Based on your location, we suggest{" "}
|
||||||
|
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ??
|
||||||
|
suggestedCurrency}{" "}
|
||||||
|
({suggestedCurrency})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
updateSetting.mutate({
|
||||||
|
key: "currency",
|
||||||
|
value: suggestedCurrency,
|
||||||
|
});
|
||||||
|
setSuggestionDismissed(true);
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium text-blue-700 hover:text-blue-800 underline ml-3"
|
||||||
|
>
|
||||||
|
Use{" "}
|
||||||
|
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ??
|
||||||
|
suggestedCurrency}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -254,10 +311,11 @@ function SettingsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">Currency</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
Market & Currency
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
Changes the currency symbol displayed. This does not convert
|
Sets your market region and currency for price display
|
||||||
values.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||||
@@ -282,6 +340,37 @@ function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
Show Converted Prices
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Display approximate conversions when local price is not available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateSetting.mutate({
|
||||||
|
key: "showConversions",
|
||||||
|
value: showConversions ? "false" : "true",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||||
|
showConversions ? "bg-blue-500" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
showConversions ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user