Deck cost calculator: BTC denomination + a full materials checklist
- Currency picker now offers BOTH Bitcoin units — ₿ sats and ₿ BTC (plus fiat). BTC is 1 per BTC, sats 1e8; switching converts between them and fiats (BTC shows up to 8 decimals, trimmed). - Default Materials is now a complete deck-build checklist, grouped: wood & core (veneer/edge tape/logs), adhesives & fillers (glue/epoxy/filler), press/mold consumables (release/vacuum film), shaping (sandpaper/saw+drill bits/masking tape), finish & graphics (stain/primer/paint/clearcoat/grip) and Other. New rows default to 0 so the worked example is unchanged.
This commit is contained in:
parent
0387d005d0
commit
885e818e3b
2 changed files with 58 additions and 16 deletions
|
|
@ -821,7 +821,8 @@ session with **no other memory of this project**. So:
|
||||||
labour, equipment amortisation, operations, materials, process time, packaging
|
labour, equipment amortisation, operations, materials, process time, packaging
|
||||||
and margin → selling price; the `deckCost` card links to it. **Currency
|
and margin → selling price; the `deckCost` card links to it. **Currency
|
||||||
(2026-10-05):** a **searchable currency selector** (Popover + search) covering
|
(2026-10-05):** a **searchable currency selector** (Popover + search) covering
|
||||||
~sats + every fiat Coinbase reports, and switching **converts every money field**
|
**sats + BTC** (both Bitcoin units) and every **fiat** Coinbase reports (crypto
|
||||||
|
is filtered out via `FIAT_CODES`), and switching **converts every money field**
|
||||||
using live BTC rates (`useBtcRates` → `getBtcRates`, one Coinbase
|
using live BTC rates (`useBtcRates` → `getBtcRates`, one Coinbase
|
||||||
`exchange-rates?currency=BTC` call — all currencies, `sats` = 1e8; unitless
|
`exchange-rates?currency=BTC` call — all currencies, `sats` = 1e8; unitless
|
||||||
fields are untouched). Falls back to relabel-only when rates are unavailable.
|
fields are untouched). Falls back to relabel-only when rates are unavailable.
|
||||||
|
|
@ -836,7 +837,10 @@ session with **no other memory of this project**. So:
|
||||||
(lazy-loaded `jspdf`) — a formatted summary of every section + the selling
|
(lazy-loaded `jspdf`) — a formatted summary of every section + the selling
|
||||||
price; the reason it's a real PDF and not print-to-PDF is that
|
price; the reason it's a real PDF and not print-to-PDF is that
|
||||||
`window.print()` doesn't work in the Capacitor WebView. A secondary **JSON**
|
`window.print()` doesn't work in the Capacitor WebView. A secondary **JSON**
|
||||||
export remains (for Import/backup); Import reads JSON. Next: write our own
|
export remains (for Import/backup); Import reads JSON. The default **Materials**
|
||||||
|
list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler,
|
||||||
|
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
|
||||||
|
paint/clearcoat/grip). Next: write our own
|
||||||
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
|
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
|
||||||
live.
|
live.
|
||||||
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
|
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,35 @@ const FIAT_CODES = new Set([
|
||||||
'ZAR', 'ZMW',
|
'ZAR', 'ZMW',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`. */
|
/** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`, `0.00012345 BTC`. */
|
||||||
function formatMoney(v: number, cur: string): string {
|
function formatMoney(v: number, cur: string): string {
|
||||||
if (cur === 'sats') return `${Math.round(v).toLocaleString()} sats`;
|
if (cur === 'sats') return `${Math.round(v).toLocaleString()} sats`;
|
||||||
|
if (cur === 'BTC') {
|
||||||
|
const str = v.toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
return `${str || '0'} BTC`;
|
||||||
|
}
|
||||||
const s = CURRENCY_SYMBOLS[cur];
|
const s = CURRENCY_SYMBOLS[cur];
|
||||||
const n = v.toFixed(2);
|
const n = v.toFixed(2);
|
||||||
if (!s) return `${cur} ${n}`;
|
if (!s) return `${cur} ${n}`;
|
||||||
return /[A-Za-z]/.test(s) ? `${s} ${n}` : `${s}${n}`;
|
return /[A-Za-z]/.test(s) ? `${s} ${n}` : `${s}${n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bitcoin is one asset shown two ways — `sats` and `BTC` are both offered. */
|
||||||
|
const BITCOIN_UNITS: Record<string, number> = { sats: 1e8, BTC: 1 };
|
||||||
|
|
||||||
|
/** Units of a currency per 1 BTC (sats/BTC = fixed; fiats come from rates). */
|
||||||
|
function ratePerBtc(cur: string, rates?: Record<string, number>): number | undefined {
|
||||||
|
return BITCOIN_UNITS[cur] ?? rates?.[cur];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chip/trigger label, e.g. `₿ sats`, `₿ BTC`, `$ USD`. */
|
||||||
|
function currencyLabel(cur: string): string {
|
||||||
|
if (cur === 'sats') return '₿ sats';
|
||||||
|
if (cur === 'BTC') return '₿ BTC';
|
||||||
|
const s = CURRENCY_SYMBOLS[cur];
|
||||||
|
return s ? `${s} ${cur}` : cur;
|
||||||
|
}
|
||||||
|
|
||||||
interface CalcState {
|
interface CalcState {
|
||||||
currency: CurrencyCode;
|
currency: CurrencyCode;
|
||||||
hourlyRate: number;
|
hourlyRate: number;
|
||||||
|
|
@ -101,13 +121,28 @@ const DEFAULT_CALC: CalcState = {
|
||||||
{ id: uid(), name: 'Other Equipment', cost: 0, setupHrs: 0, boards: 100000 },
|
{ id: uid(), name: 'Other Equipment', cost: 0, setupHrs: 0, boards: 100000 },
|
||||||
],
|
],
|
||||||
materials: [
|
materials: [
|
||||||
{ id: uid(), name: 'Veneer', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 },
|
// Wood & core
|
||||||
{ id: uid(), name: 'Glue', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 },
|
{ id: uid(), name: 'Veneer (maple plies)', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 },
|
||||||
|
{ id: uid(), name: 'Veneer tape / edge tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 5, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Logs (if milling your own veneer)', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 },
|
||||||
|
// Adhesives & fillers
|
||||||
|
{ id: uid(), name: 'Wood glue / epoxy resin', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Wood filler / putty', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
|
||||||
|
// Press / mold consumables
|
||||||
|
{ id: uid(), name: 'Mold release / wax paper', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Vacuum bag / breather film', orderCost: 0, shipping: 0, boards: 200, orderMin: 0, unloadMin: 0 },
|
||||||
|
// Shaping consumables
|
||||||
{ id: uid(), name: 'Sandpaper', orderCost: 30, shipping: 0, boards: 100, orderMin: 5, unloadMin: 15 },
|
{ id: uid(), name: 'Sandpaper', orderCost: 30, shipping: 0, boards: 100, orderMin: 5, unloadMin: 15 },
|
||||||
{ id: uid(), name: 'Bits', orderCost: 50, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
|
{ id: uid(), name: 'Saw blades', orderCost: 20, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
|
||||||
{ id: uid(), name: 'Saw Blades', orderCost: 20, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
|
{ id: uid(), name: 'Drill bits / router bits', orderCost: 50, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 },
|
||||||
{ id: uid(), name: 'Logs', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 },
|
{ id: uid(), name: 'Masking tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 },
|
||||||
{ id: uid(), name: 'Other (inks, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 },
|
// Finish & graphics
|
||||||
|
{ id: uid(), name: 'Stain / dye', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Primer / base coat', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Paint / screen-print ink', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Clearcoat / lacquer', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Grip tape', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Other (hardware, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 },
|
||||||
],
|
],
|
||||||
processes: [
|
processes: [
|
||||||
{ id: uid(), name: 'Glue & Press', minutes: 20, boards: 3 },
|
{ id: uid(), name: 'Glue & Press', minutes: 20, boards: 3 },
|
||||||
|
|
@ -154,7 +189,7 @@ export function DeckCostCalculator() {
|
||||||
const fiat = rates
|
const fiat = rates
|
||||||
? Object.keys(rates).filter((c) => FIAT_CODES.has(c)).sort()
|
? Object.keys(rates).filter((c) => FIAT_CODES.has(c)).sort()
|
||||||
: ['USD', 'EUR', 'GBP'];
|
: ['USD', 'EUR', 'GBP'];
|
||||||
return ['sats', ...fiat];
|
return ['sats', 'BTC', ...fiat];
|
||||||
}, [rates]);
|
}, [rates]);
|
||||||
const filteredCurrencies = query
|
const filteredCurrencies = query
|
||||||
? currencies.filter((c) => c.toLowerCase().includes(query.toLowerCase()))
|
? currencies.filter((c) => c.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
|
@ -167,11 +202,14 @@ export function DeckCostCalculator() {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setS((prev) => {
|
setS((prev) => {
|
||||||
if (to === prev.currency) return prev;
|
if (to === prev.currency) return prev;
|
||||||
const rf = prev.currency === 'sats' ? 1e8 : rates?.[prev.currency];
|
const rf = ratePerBtc(prev.currency, rates);
|
||||||
const rt = to === 'sats' ? 1e8 : rates?.[to];
|
const rt = ratePerBtc(to, rates);
|
||||||
if (!rf || !rt) return { ...prev, currency: to }; // no rate → relabel only
|
if (!rf || !rt) return { ...prev, currency: to }; // no rate → relabel only
|
||||||
const k = rt / rf;
|
const k = rt / rf;
|
||||||
const r2 = (v: number) => (to === 'sats' ? Math.round(v * k) : Math.round(v * k * 100) / 100);
|
const r2 = (v: number) =>
|
||||||
|
to === 'sats' ? Math.round(v * k)
|
||||||
|
: to === 'BTC' ? Math.round(v * k * 1e8) / 1e8
|
||||||
|
: Math.round(v * k * 100) / 100;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
currency: to,
|
currency: to,
|
||||||
|
|
@ -276,7 +314,7 @@ export function DeckCostCalculator() {
|
||||||
setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26;
|
setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26;
|
||||||
setText(9, false, 120);
|
setText(9, false, 120);
|
||||||
doc.text(
|
doc.text(
|
||||||
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur}`,
|
`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur === 'BTC' ? 'BTC (Bitcoin)' : cur}`,
|
||||||
M, y,
|
M, y,
|
||||||
);
|
);
|
||||||
y += 10;
|
y += 10;
|
||||||
|
|
@ -390,7 +428,7 @@ export function DeckCostCalculator() {
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-h-0 items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1.5 text-xs font-bold text-[#ccff00] hover:bg-[#ccff00]/15"
|
className="flex min-h-0 items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1.5 text-xs font-bold text-[#ccff00] hover:bg-[#ccff00]/15"
|
||||||
>
|
>
|
||||||
{cur === 'sats' ? '₿ sats' : `${CURRENCY_SYMBOLS[cur] ?? ''} ${cur}`.trim()}
|
{currencyLabel(cur)}
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -416,7 +454,7 @@ export function DeckCostCalculator() {
|
||||||
c === cur ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'text-gray-300 hover:bg-white/5',
|
c === cur ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'text-gray-300 hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{c === 'sats' ? '₿ sats' : `${CURRENCY_SYMBOLS[c] ?? ''} ${c}`.trim()}</span>
|
<span>{currencyLabel(c)}</span>
|
||||||
{c === cur && <Check className="h-3.5 w-3.5" />}
|
{c === cur && <Check className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue