diff --git a/AGENTS.md b/AGENTS.md index d6c87ae..ef53596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -825,7 +825,14 @@ session with **no other memory of this project**. So: 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 + **Polish (2026-10-05):** `Setup (h)` now feeds equipment amortisation + (`(cost + setupHrs × labor/hr) / boards`); sats round to whole numbers; each + section shows a per-board subtotal; a **sticky summary bar** keeps cost/price + visible; **Save/Load named presets + JSON Export/Import** (localStorage + + download/file), and Reset now confirms. The results include a + **"List a deck at this price →"** link to `/create-listing?price¤cy` + (CreateListingPage reads both params) and **"Get it made →"** to + `/lab?craft=building`. 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 489d920..54638d2 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Plus, Trash2, RotateCcw, Search, ChevronDown, Check } from 'lucide-react'; +import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useBtcRates } from '@/hooks/useExchangeRate'; import { cn } from '@/lib/utils'; @@ -125,11 +126,24 @@ const DEFAULT_CALC: CalcState = { ], }; +/** A pristine default with fresh row ids (so React keys don't collide). */ +function freshDefault(): CalcState { + return { + ...DEFAULT_CALC, + equipment: DEFAULT_CALC.equipment.map((e) => ({ ...e, id: uid() })), + materials: DEFAULT_CALC.materials.map((m) => ({ ...m, id: uid() })), + processes: DEFAULT_CALC.processes.map((p) => ({ ...p, id: uid() })), + packaging: DEFAULT_CALC.packaging.map((p) => ({ ...p, id: uid() })), + }; +} + 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 [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []); + const [loadOpen, setLoadOpen] = useState(false); const cur = s.currency; const unit = cur === 'sats' ? 'sats' : (CURRENCY_SYMBOLS[cur] ?? cur); @@ -156,7 +170,7 @@ export function DeckCostCalculator() { 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; + const r2 = (v: number) => (to === 'sats' ? Math.round(v * k) : Math.round(v * k * 100) / 100); return { ...prev, currency: to, @@ -172,8 +186,9 @@ export function DeckCostCalculator() { }; const r = useMemo(() => { - const laborPerMin = (s.hourlyRate * s.overheadFactor) / 60; - const equipment = s.equipment.reduce((a, e) => a + (e.boards > 0 ? e.cost / e.boards : 0), 0); + const laborPerHr = s.hourlyRate * s.overheadFactor; + const laborPerMin = laborPerHr / 60; + const equipment = s.equipment.reduce((a, e) => a + (e.boards > 0 ? (e.cost + e.setupHrs * laborPerHr) / e.boards : 0), 0); const operations = s.boardsPerMonth > 0 ? (s.rent + s.utilities + s.subscriptions) / s.boardsPerMonth : 0; const materials = s.materials.reduce((a, m) => a + (m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * laborPerMin) / m.boards : 0), 0); const processes = s.processes.reduce((a, p) => a + (p.boards > 0 ? (p.minutes * laborPerMin) / p.boards : 0), 0); @@ -182,6 +197,7 @@ export function DeckCostCalculator() { const costWithPack = costNoPack + packaging; const factor = s.scrapPct < 100 ? s.profitMargin / (1 - s.scrapPct / 100) : s.profitMargin; return { + laborPerHr, laborPerMin, equipment, operations, @@ -197,6 +213,37 @@ export function DeckCostCalculator() { const patch = (p: Partial) => setS((prev) => ({ ...prev, ...p })); + const savePreset = () => { + const name = window.prompt('Name this preset', `Preset ${presets.length + 1}`); + if (!name) return; + setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]); + }; + const loadPreset = (state: CalcState) => { setS(state); setLoadOpen(false); }; + const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name)); + const exportJson = () => { + const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'deck-cost.json'; + a.click(); + URL.revokeObjectURL(url); + }; + const importJson = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { setS({ ...freshDefault(), ...(JSON.parse(String(reader.result)) as CalcState) }); } catch { /* ignore malformed */ } + }; + reader.readAsText(file); + e.target.value = ''; + }; + const reset = () => { + if (!window.confirm('Reset everything to the example values?')) return; + setS(freshDefault()); + }; + const numInput = (value: number, onChange: (n: number) => void, className?: string) => ( {/* Currency — searchable; switching CONVERTS every money field via live BTC rates (sats included). */} -
+
Currency @@ -263,6 +310,9 @@ export function DeckCostCalculator() { {rates && live BTC rates · sats included} + + {fmt(r.costWithPack)} cost · {fmt(r.priceWithPack)} sell +
{/* Labor */}
@@ -298,13 +348,14 @@ export function DeckCostCalculator() { {numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))} {numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))} {numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))} - {fmt(e.boards > 0 ? e.cost / e.boards : 0)} + {fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} ))}
+

Equipment per board: {fmt(r.equipment)}

@@ -348,6 +399,7 @@ export function DeckCostCalculator() { +

Materials per board: {fmt(r.materials)}

@@ -376,6 +428,7 @@ export function DeckCostCalculator() { +

Processes per board: {fmt(r.processes)}

@@ -404,6 +457,7 @@ export function DeckCostCalculator() { +

Packaging per board: {fmt(r.packaging)}

@@ -430,11 +484,56 @@ export function DeckCostCalculator() {

Does not include shipping to the buyer.

+ + {/* Turn the number into an action: list it at this price, or find someone + to build it. */} +
+ + List a deck at this price + + + Get it made — find a builder + +
- + {/* Presets & data */} +
+ + + + + + + {presets.map((p) => ( +
+ + +
+ ))} +
+
+ + + +
); } diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx index 3e6ba73..33c45a8 100644 --- a/src/pages/CreateListingPage.tsx +++ b/src/pages/CreateListingPage.tsx @@ -76,7 +76,7 @@ export default function CreateListingPage() { const [condition, setCondition] = useState(searchParams.get('condition') || ''); const [priceType, setPriceType] = useState(searchParams.get('priceType') || 'fixed'); const [price, setPrice] = useState(searchParams.get('price') || ''); - const [currency, setCurrency] = useState('sats'); // Default to sats when coming from stash (stash uses sats) + const [currency, setCurrency] = useState(searchParams.get('currency') || 'sats'); // Default sats; deep-links (DIY calculator) can preselect const [size, setSize] = useState(searchParams.get('size') || ''); const [truckHeight, setTruckHeight] = useState(''); const [wheelHardness, setWheelHardness] = useState('');