Deck cost calculator: correctness + presets + a marketplace hand-off
- Equipment now amortises setup time too: (cost + setupHrs × labor/hr) ÷ boards, so the Setup (h) column actually does something. - sats values round to whole numbers on currency conversion. - Each section shows a per-board subtotal (equipment/materials/processes/packaging). - Sticky summary bar (under the header) keeps cost + selling price visible while you edit. - Save/Load named presets (localStorage) + Export/Import JSON; Reset now confirms. - Results gain "List a deck at this price →" (deep-links CreateListingPage with ?price¤cy, which now reads currency too) and "Get it made →" (Lab Builder).
This commit is contained in:
parent
985801e19e
commit
f3242e2fed
3 changed files with 117 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CalcState>('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<CalcState>) => 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<HTMLInputElement>) => {
|
||||
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) => (
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -215,7 +262,7 @@ export function DeckCostCalculator() {
|
|||
<div className="space-y-5">
|
||||
{/* Currency — searchable; switching CONVERTS every money field via live
|
||||
BTC rates (sats included). */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="sticky top-16 z-20 -mx-1 flex flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-black/90 px-3 py-2 backdrop-blur">
|
||||
<span className="text-[10px] font-black uppercase tracking-wider text-gray-500">Currency</span>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -263,6 +310,9 @@ export function DeckCostCalculator() {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
{rates && <span className="text-[10px] text-gray-500">live BTC rates · sats included</span>}
|
||||
<span className="ml-auto text-[11px] font-bold text-gray-400">
|
||||
{fmt(r.costWithPack)} cost · <span className="text-[#ccff00]">{fmt(r.priceWithPack)} sell</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Labor */}
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||
|
|
@ -298,13 +348,14 @@ export function DeckCostCalculator() {
|
|||
<td className={td}>{numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))}</td>
|
||||
<td className={td}>{numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))}</td>
|
||||
<td className={td}>{numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))}</td>
|
||||
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(e.boards > 0 ? e.cost / e.boards : 0)}</td>
|
||||
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}</td>
|
||||
<td className={td}><button onClick={() => patch({ equipment: s.equipment.filter((x) => x.id !== e.id) })} className="min-h-0 min-w-0 text-gray-600 hover:text-red-400" aria-label="Remove"><Trash2 className="h-3.5 w-3.5" /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Equipment per board: <span className="font-bold text-gray-300">{fmt(r.equipment)}</span></p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ equipment: [...s.equipment, { id: uid(), name: 'New', cost: 0, setupHrs: 0, boards: 1 }] })}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add equipment
|
||||
</Button>
|
||||
|
|
@ -348,6 +399,7 @@ export function DeckCostCalculator() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Materials per board: <span className="font-bold text-gray-300">{fmt(r.materials)}</span></p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ materials: [...s.materials, { id: uid(), name: 'New', orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 }] })}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add material
|
||||
</Button>
|
||||
|
|
@ -376,6 +428,7 @@ export function DeckCostCalculator() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Processes per board: <span className="font-bold text-gray-300">{fmt(r.processes)}</span></p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ processes: [...s.processes, { id: uid(), name: 'New', minutes: 0, boards: 1 }] })}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add process
|
||||
</Button>
|
||||
|
|
@ -404,6 +457,7 @@ export function DeckCostCalculator() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Packaging per board: <span className="font-bold text-gray-300">{fmt(r.packaging)}</span></p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-2 h-7 gap-1 text-xs text-gray-400" onClick={() => patch({ packaging: [...s.packaging, { id: uid(), name: 'New', minutesPerBoard: 0, materialsPerBoard: 0 }] })}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add packaging step
|
||||
</Button>
|
||||
|
|
@ -430,11 +484,56 @@ export function DeckCostCalculator() {
|
|||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
|
||||
|
||||
{/* Turn the number into an action: list it at this price, or find someone
|
||||
to build it. */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/create-listing?price=${encodeURIComponent(String(Math.round(r.priceWithPack * 100) / 100))}¤cy=${encodeURIComponent(cur)}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-4 py-2 text-xs font-black uppercase tracking-wide text-black transition-colors hover:bg-[#ccff00]/90"
|
||||
>
|
||||
List a deck at this price <ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/lab?craft=building"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/15 px-4 py-2 text-xs font-bold text-gray-300 transition-colors hover:bg-white/5"
|
||||
>
|
||||
Get it made — find a builder
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={() => setS({ ...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() })) })}>
|
||||
<RotateCcw className="h-3.5 w-3.5" /> Reset to example
|
||||
</Button>
|
||||
{/* Presets & data */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={savePreset}>
|
||||
<Save className="h-3.5 w-3.5" /> Save preset
|
||||
</Button>
|
||||
<Popover open={loadOpen} onOpenChange={setLoadOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" disabled={presets.length === 0}>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> Load{presets.length > 0 ? ` (${presets.length})` : ''}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
{presets.map((p) => (
|
||||
<div key={p.name} className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => loadPreset(p.state)} className="min-h-0 flex-1 rounded-lg px-2.5 py-1.5 text-left text-xs font-semibold text-gray-300 hover:bg-white/5">{p.name}</button>
|
||||
<button type="button" onClick={() => deletePreset(p.name)} aria-label="Delete preset" className="min-h-0 min-w-0 p-1 text-gray-600 hover:text-red-400"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={exportJson}>
|
||||
<Download className="h-3.5 w-3.5" /> Export
|
||||
</Button>
|
||||
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
|
||||
<Upload className="h-3.5 w-3.5" /> Import
|
||||
<input type="file" accept="application/json" className="hidden" onChange={importJson} />
|
||||
</label>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 border-white/15 text-xs" onClick={reset}>
|
||||
<RotateCcw className="h-3.5 w-3.5" /> Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export default function CreateListingPage() {
|
|||
const [condition, setCondition] = useState<string>(searchParams.get('condition') || '');
|
||||
const [priceType, setPriceType] = useState<string>(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('');
|
||||
|
|
|
|||
Loading…
Reference in a new issue