From ee556696254b94547ef3214465b4e5db1f5a0c9c Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 10:16:50 +0700 Subject: [PATCH] Deck cost calculator: convert + a searchable multi-currency selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getBtcRates()/useBtcRates() read every BTC→fiat rate from the ONE Coinbase exchange-rates call the app already made, plus sats (1e8). No new endpoint. - The calculator's currency control is now a searchable Popover: sats + every fiat Coinbase reports, with a search box. Selecting a currency CONVERTS every money field (labour, rent, utilities, subscriptions, equipment cost, material order/shipping, packaging materials) by the live ratio; unitless fields (overhead factor, board counts, minutes, margins) are untouched. Falls back to relabel-only if rates aren't loaded. - Currency symbols/formats for the common fiats. --- AGENTS.md | 12 ++- src/components/DeckCostCalculator.tsx | 128 +++++++++++++++++++++----- src/hooks/useExchangeRate.ts | 14 ++- src/lib/exchangeRateService.ts | 39 ++++++++ 4 files changed, 166 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 061653e..d6c87ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -819,11 +819,13 @@ session with **no other memory of this project**. So: calculator (2026-10-05):** the first in-app tool lives at `/diy/deck-cost` (`components/DeckCostCalculator.tsx`) — a self-contained (localStorage) model of labour, equipment amortisation, operations, materials, process time, packaging - and margin → selling price; the `deckCost` card links to it. Includes a - **currency unit toggle** (USD/EUR/GBP/sats) that relabels/reformats the whole - sheet — it **does not convert** values (only a BTC/USD rate is available, so no - fiat↔fiat or reliable fiat↔sats conversion; enter amounts in the unit you - think in). Next: write our own + and margin → selling price; the `deckCost` card links to it. **Currency + (2026-10-05):** a **searchable currency selector** (Popover + search) covering + ~sats + every fiat Coinbase reports, and switching **converts every money field** + using live BTC rates (`useBtcRates` → `getBtcRates`, one Coinbase + `exchange-rates?currency=BTC` call — all currencies, `sats` = 1e8; unitless + fields are untouched). Falls back to relabel-only when rates are unavailable. + Next: write our own step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards live. - **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 245ddc4..44f7a82 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -1,8 +1,10 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Plus, Trash2, RotateCcw } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Plus, Trash2, RotateCcw, Search, ChevronDown, Check } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useBtcRates } from '@/hooks/useExchangeRate'; import { cn } from '@/lib/utils'; /** @@ -20,9 +22,25 @@ interface MaterialRow { id: string; name: string; orderCost: number; shipping: n interface ProcessRow { id: string; name: string; minutes: number; boards: number } interface PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number } -type CurrencyCode = 'USD' | 'EUR' | 'GBP' | 'sats'; +type CurrencyCode = string; -const CURRENCY_SYMBOL: Record = { USD: '$', EUR: '€', GBP: '£', sats: '' }; +const CURRENCY_SYMBOLS: Record = { + USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', AUD: 'A$', CAD: 'C$', CHF: 'CHF', HKD: 'HK$', + SGD: 'S$', NZD: 'NZ$', SEK: 'kr', NOK: 'kr', DKK: 'kr', PLN: 'zł', CZK: 'Kč', HUF: 'Ft', + RON: 'lei', BGN: 'лв', TRY: '₺', RUB: '₽', UAH: '₴', INR: '₹', KRW: '₩', TWD: 'NT$', + THB: '฿', MYR: 'RM', IDR: 'Rp', PHP: '₱', VND: '₫', BRL: 'R$', MXN: 'MX$', ARS: 'AR$', + CLP: 'CL$', COP: 'CO$', PEN: 'S/', ZAR: 'R', NGN: '₦', KES: 'KSh', GHS: '₵', EGP: 'E£', + ILS: '₪', PKR: '₨', BDT: '৳', LKR: 'Rs', NPR: 'Rs', +}; + +/** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`. */ +function formatMoney(v: number, cur: string): string { + if (cur === 'sats') return `${Math.round(v).toLocaleString()} sats`; + const s = CURRENCY_SYMBOLS[cur]; + const n = v.toFixed(2); + if (!s) return `${cur} ${n}`; + return /[A-Za-z]/.test(s) ? `${s} ${n}` : `${s}${n}`; +} interface CalcState { currency: CurrencyCode; @@ -92,12 +110,46 @@ const DEFAULT_CALC: CalcState = { export function DeckCostCalculator() { const [s, setS] = useLocalStorage('skatespots:deck-cost', DEFAULT_CALC); + const { data: rates } = useBtcRates(); + const [pickerOpen, setPickerOpen] = useState(false); + const [query, setQuery] = useState(''); const cur = s.currency; - const unit = cur === 'sats' ? 'sats' : CURRENCY_SYMBOL[cur]; - const fmt = (n: number) => { - const v = Number.isFinite(n) ? n : 0; - return cur === 'sats' ? `${Math.round(v).toLocaleString()} sats` : `${CURRENCY_SYMBOL[cur]}${v.toFixed(2)}`; + const unit = cur === 'sats' ? 'sats' : (CURRENCY_SYMBOLS[cur] ?? cur); + const fmt = (n: number) => formatMoney(Number.isFinite(n) ? n : 0, cur); + + const currencies = useMemo(() => { + const fiat = rates ? Object.keys(rates).sort() : ['USD', 'EUR', 'GBP']; + return ['sats', ...fiat]; + }, [rates]); + const filteredCurrencies = query + ? currencies.filter((c) => c.toLowerCase().includes(query.toLowerCase())) + : currencies; + + // Convert every MONEY field when the unit changes (unitless fields like the + // overhead factor, board counts, minutes and margins are left untouched). + const changeCurrency = (to: CurrencyCode) => { + setPickerOpen(false); + setQuery(''); + setS((prev) => { + if (to === prev.currency) return prev; + const rf = prev.currency === 'sats' ? 1e8 : rates?.[prev.currency]; + const rt = to === 'sats' ? 1e8 : rates?.[to]; + if (!rf || !rt) return { ...prev, currency: to }; // no rate → relabel only + const k = rt / rf; + const r2 = (v: number) => Math.round(v * k * 100) / 100; + return { + ...prev, + currency: to, + hourlyRate: r2(prev.hourlyRate), + rent: r2(prev.rent), + utilities: r2(prev.utilities), + subscriptions: r2(prev.subscriptions), + equipment: prev.equipment.map((e) => ({ ...e, cost: r2(e.cost) })), + materials: prev.materials.map((m) => ({ ...m, orderCost: r2(m.orderCost), shipping: r2(m.shipping) })), + packaging: prev.packaging.map((p) => ({ ...p, materialsPerBoard: r2(p.materialsPerBoard) })), + }; + }); }; const r = useMemo(() => { @@ -142,22 +194,56 @@ export function DeckCostCalculator() { return (
- {/* Currency — reformats the whole sheet (enter amounts in the unit you think in) */} + {/* Currency — searchable; switching CONVERTS every money field via live + BTC rates (sats included). */}
Currency - {(['USD', 'EUR', 'GBP', 'sats'] as CurrencyCode[]).map((c) => ( - + + +
+ + setQuery(e.target.value)} + placeholder="Search currency…" + className="h-8 border-white/10 bg-white/5 pl-8 text-xs" + /> +
+
+ {filteredCurrencies.map((c) => ( + + ))} + {filteredCurrencies.length === 0 && ( +

No currency matches.

+ )} +
+ {!rates && ( +

Live rates unavailable — switching relabels only.

)} - > - {c === 'sats' ? '₿ sats' : `${CURRENCY_SYMBOL[c]} ${c}`} - - ))} +
+ + {rates && live BTC rates · sats included}
{/* Labor */}
diff --git a/src/hooks/useExchangeRate.ts b/src/hooks/useExchangeRate.ts index c3ff79e..7484e5a 100644 --- a/src/hooks/useExchangeRate.ts +++ b/src/hooks/useExchangeRate.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { getBtcUsdRate } from '@/lib/exchangeRateService'; +import { getBtcUsdRate, getBtcRates } from '@/lib/exchangeRateService'; /** * Hook to fetch and cache BTC/USD exchange rate @@ -16,6 +16,18 @@ export function useExchangeRate() { }); } +/** All BTC → fiat rates (Coinbase) + `sats`. For multi-currency conversion. */ +export function useBtcRates() { + return useQuery({ + queryKey: ['btc-rates'], + queryFn: getBtcRates, + staleTime: 60000, + refetchInterval: 120000, + retry: 2, + retryDelay: 1000, + }); +} + /** * Hook to convert sats to USD with current exchange rate */ diff --git a/src/lib/exchangeRateService.ts b/src/lib/exchangeRateService.ts index 07c84ab..ab8090f 100644 --- a/src/lib/exchangeRateService.ts +++ b/src/lib/exchangeRateService.ts @@ -8,6 +8,43 @@ const CACHE_DURATION_MS = 60000; // 1 minute cache let cachedRate: number | null = null; let cacheTimestamp: number = 0; +let cachedRates: Record | null = null; +let ratesTimestamp: number = 0; + +/** + * All BTC → fiat rates Coinbase knows about, plus `sats` (= 1e8 per BTC). One + * request; cached. Used by the DIY deck-cost calculator's currency converter. + */ +export async function getBtcRates(): Promise> { + const now = Date.now(); + if (cachedRates && (now - ratesTimestamp) < CACHE_DURATION_MS) return cachedRates; + + const response = await fetch(`${COINBASE_API_URL}?currency=BTC`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + throw new Error(`Coinbase API error: ${response.status} ${response.statusText}`); + } + const data = await response.json(); + const raw = data?.data?.rates; + if (!raw || typeof raw !== 'object') { + throw new Error('Invalid response format from Coinbase API'); + } + + const out: Record = {}; + for (const [code, value] of Object.entries(raw as Record)) { + const n = parseFloat(value); + if (Number.isFinite(n) && n > 0) out[code] = n; + } + // sats per BTC — a fixed unit, not a fiat rate. + out.sats = 100000000; + + cachedRates = out; + ratesTimestamp = now; + return out; +} /** * Get the current BTC/USD exchange rate from Coinbase API @@ -118,6 +155,8 @@ export async function usdToSats(usdAmount: number): Promise { export function clearCache(): void { cachedRate = null; cacheTimestamp = 0; + cachedRates = null; + ratesTimestamp = 0; } /**