DIY: drop the category chips; add the deck cost calculator
- Removed the sticky category-chip row from /diy (sections still group the cards). - New in-app tool at /diy/deck-cost (components/DeckCostCalculator.tsx): a self-contained pricing model — labour rate + overhead, equipment amortisation, operation expenses, materials (with order/unload time), process time, packaging, profit margin and scrap → cost per board and a fair selling price (no-packaging / with-packaging). State persists to localStorage; reset to the worked example. The "Deck cost & pricing" card now links to it instead of showing "Coming soon". - Smoke test walks /diy/deck-cost.
This commit is contained in:
parent
30d46ddfed
commit
b90fef1d2e
9 changed files with 360 additions and 17 deletions
|
|
@ -815,9 +815,13 @@ session with **no other memory of this project**. So:
|
||||||
item (id `diy`, Hammer) so it shows in the desktop "More" menu **and** the
|
item (id `diy`, Hammer) so it shows in the desktop "More" menu **and** the
|
||||||
mobile burger, and is linked from the **site footer** + the **map links strip**.
|
mobile burger, and is linked from the **site footer** + the **map links strip**.
|
||||||
Cross-links to `/lab?craft=building` (Builder), `/crews`, `/create-spot`,
|
Cross-links to `/lab?craft=building` (Builder), `/crews`, `/create-spot`,
|
||||||
`/create-listing`, `/lab?craft=coaching`, `/marketplace`. Next: write our own
|
`/create-listing`, `/lab?craft=coaching`, `/marketplace`. **Deck cost
|
||||||
|
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. 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, plus the in-app deck cost calculator.
|
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
|
||||||
paid in **sats only (Lightning or Cashu)** — no cards/accounts — sold
|
paid in **sats only (Lightning or Cashu)** — no cards/accounts — sold
|
||||||
**locally, on request**: there is **no cart, no checkout, no shipping**. Buying
|
**locally, on request**: there is **no cart, no checkout, no shipping**. Buying
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ const ROUTES = [
|
||||||
'/mints',
|
'/mints',
|
||||||
'/app-shop',
|
'/app-shop',
|
||||||
'/diy',
|
'/diy',
|
||||||
|
'/diy/deck-cost',
|
||||||
'/review',
|
'/review',
|
||||||
'/card',
|
'/card',
|
||||||
'/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
|
'/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const WalletPage = lazyWithRetry(() => import("./pages/WalletPage"));
|
||||||
const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
|
const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
|
||||||
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
|
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
|
||||||
const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
|
const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
|
||||||
|
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
||||||
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
||||||
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
||||||
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
||||||
|
|
@ -143,6 +144,7 @@ export function AppRouter() {
|
||||||
<Route path="/mints" element={<MintsPage />} />
|
<Route path="/mints" element={<MintsPage />} />
|
||||||
<Route path="/app-shop" element={<AppShopPage />} />
|
<Route path="/app-shop" element={<AppShopPage />} />
|
||||||
<Route path="/diy" element={<DiyPage />} />
|
<Route path="/diy" element={<DiyPage />} />
|
||||||
|
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
||||||
<Route path="/review" element={<AdminReviewPage />} />
|
<Route path="/review" element={<AdminReviewPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/create-spot" element={<CreateSpotPage />} />
|
<Route path="/create-spot" element={<CreateSpotPage />} />
|
||||||
|
|
|
||||||
306
src/components/DeckCostCalculator.tsx
Normal file
306
src/components/DeckCostCalculator.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck cost calculator — a self-contained tool for working out what a deck
|
||||||
|
* costs to make and a fair selling price (labour, equipment amortisation,
|
||||||
|
* operation expenses, materials, process time, packaging, margin, scrap).
|
||||||
|
*
|
||||||
|
* No server, no accounts: state lives in localStorage. Mirrors the well-known
|
||||||
|
* open-source skateboard pricing model. Column headers stay English on purpose
|
||||||
|
* (technical terms); results are labelled by the host page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface EquipmentRow { id: string; name: string; cost: number; setupHrs: number; boards: number }
|
||||||
|
interface MaterialRow { id: string; name: string; orderCost: number; shipping: number; boards: number; orderMin: number; unloadMin: number }
|
||||||
|
interface ProcessRow { id: string; name: string; minutes: number; boards: number }
|
||||||
|
interface PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number }
|
||||||
|
|
||||||
|
interface CalcState {
|
||||||
|
hourlyRate: number;
|
||||||
|
overheadFactor: number;
|
||||||
|
boardsPerMonth: number;
|
||||||
|
rent: number;
|
||||||
|
utilities: number;
|
||||||
|
subscriptions: number;
|
||||||
|
profitMargin: number;
|
||||||
|
scrapPct: number;
|
||||||
|
equipment: EquipmentRow[];
|
||||||
|
materials: MaterialRow[];
|
||||||
|
processes: ProcessRow[];
|
||||||
|
packaging: PackageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = () => Math.random().toString(36).slice(2, 9);
|
||||||
|
|
||||||
|
const DEFAULT_CALC: CalcState = {
|
||||||
|
hourlyRate: 20,
|
||||||
|
overheadFactor: 2.5,
|
||||||
|
boardsPerMonth: 160,
|
||||||
|
rent: 1000,
|
||||||
|
utilities: 100,
|
||||||
|
subscriptions: 50,
|
||||||
|
profitMargin: 2.5,
|
||||||
|
scrapPct: 5,
|
||||||
|
equipment: [
|
||||||
|
{ id: uid(), name: 'Molds', cost: 900, setupHrs: 1, boards: 10000 },
|
||||||
|
{ id: uid(), name: 'Press', cost: 5000, setupHrs: 1, boards: 5000 },
|
||||||
|
{ id: uid(), name: '3D Printer', cost: 900, setupHrs: 8, boards: 100000 },
|
||||||
|
{ id: uid(), name: 'CNC Router', cost: 6000, setupHrs: 24, boards: 40000 },
|
||||||
|
{ id: uid(), name: 'Saws', cost: 200, setupHrs: 0, boards: 10000 },
|
||||||
|
{ id: uid(), name: 'Sanders', cost: 200, setupHrs: 0, boards: 5000 },
|
||||||
|
{ id: uid(), name: 'Spraying Equipment', cost: 500, setupHrs: 0, boards: 100000 },
|
||||||
|
{ id: uid(), name: 'Graphics Equipment', cost: 400, setupHrs: 8, boards: 10000 },
|
||||||
|
{ id: uid(), name: 'Other Equipment', cost: 0, setupHrs: 0, boards: 100000 },
|
||||||
|
],
|
||||||
|
materials: [
|
||||||
|
{ id: uid(), name: 'Veneer', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 },
|
||||||
|
{ id: uid(), name: 'Glue', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 },
|
||||||
|
{ 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: 'Logs', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 },
|
||||||
|
{ id: uid(), name: 'Other (inks, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 },
|
||||||
|
],
|
||||||
|
processes: [
|
||||||
|
{ id: uid(), name: 'Glue & Press', minutes: 20, boards: 3 },
|
||||||
|
{ id: uid(), name: 'Marking & Registration', minutes: 2, boards: 1 },
|
||||||
|
{ id: uid(), name: 'Drilling', minutes: 5, boards: 3 },
|
||||||
|
{ id: uid(), name: 'Shaping', minutes: 4, boards: 1 },
|
||||||
|
{ id: uid(), name: 'Routing Edges', minutes: 1, boards: 1 },
|
||||||
|
{ id: uid(), name: 'Sanding', minutes: 10, boards: 1 },
|
||||||
|
{ id: uid(), name: 'Clearcoating', minutes: 5, boards: 1 },
|
||||||
|
{ id: uid(), name: 'Graphics', minutes: 4, boards: 1 },
|
||||||
|
{ id: uid(), name: 'Other Processes', minutes: 0, boards: 1 },
|
||||||
|
],
|
||||||
|
packaging: [
|
||||||
|
{ id: uid(), name: 'Sticker & poly bag', minutesPerBoard: 4, materialsPerBoard: 0.06 },
|
||||||
|
{ id: uid(), name: 'Pack board', minutesPerBoard: 8, materialsPerBoard: 0.67 },
|
||||||
|
{ id: uid(), name: 'Drop off', minutesPerBoard: 10, materialsPerBoard: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const money = (n: number) => `$${(Number.isFinite(n) ? n : 0).toFixed(2)}`;
|
||||||
|
|
||||||
|
export function DeckCostCalculator() {
|
||||||
|
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
const packaging = s.packaging.reduce((a, p) => a + p.materialsPerBoard + p.minutesPerBoard * laborPerMin, 0);
|
||||||
|
const costNoPack = equipment + operations + materials + processes;
|
||||||
|
const costWithPack = costNoPack + packaging;
|
||||||
|
const factor = s.scrapPct < 100 ? s.profitMargin / (1 - s.scrapPct / 100) : s.profitMargin;
|
||||||
|
return {
|
||||||
|
laborPerMin,
|
||||||
|
equipment,
|
||||||
|
operations,
|
||||||
|
materials,
|
||||||
|
processes,
|
||||||
|
packaging,
|
||||||
|
costNoPack,
|
||||||
|
costWithPack,
|
||||||
|
priceNoPack: costNoPack * factor,
|
||||||
|
priceWithPack: costWithPack * factor,
|
||||||
|
};
|
||||||
|
}, [s]);
|
||||||
|
|
||||||
|
const patch = (p: Partial<CalcState>) => setS((prev) => ({ ...prev, ...p }));
|
||||||
|
|
||||||
|
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={Number.isFinite(value) ? value : 0}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||||||
|
className={`h-8 min-w-16 ${className ?? ''}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const th = 'px-2 py-1 text-left text-[10px] font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap';
|
||||||
|
const td = 'px-1 py-1 align-middle';
|
||||||
|
const label = 'text-[11px] font-semibold text-gray-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Labor */}
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Labor</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className={label}>Hourly rate ($)</span>
|
||||||
|
{numInput(s.hourlyRate, (n) => patch({ hourlyRate: n }))}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className={label}>Overhead factor (2–3)</span>
|
||||||
|
{numInput(s.overheadFactor, (n) => patch({ overheadFactor: n }))}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className={label}>Labor cost</span>
|
||||||
|
<div className="flex h-8 items-center rounded-md border border-white/10 bg-black/40 px-2 text-sm font-bold text-[#ccff00]">
|
||||||
|
{money(s.hourlyRate * s.overheadFactor)}/hr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Equipment */}
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Equipment</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead><tr><th className={th}>Equipment</th><th className={th}>Cost ($)</th><th className={th}>Setup (h)</th><th className={th}># boards</th><th className={th}>Per board</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{s.equipment.map((e) => (
|
||||||
|
<tr key={e.id} className="border-t border-white/5">
|
||||||
|
<td className={td}><Input value={e.name} onChange={(ev) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /></td>
|
||||||
|
<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`}>{money(e.boards > 0 ? e.cost / 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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Operations */}
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Operation expenses</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<label className="space-y-1"><span className={label}>Boards / month</span>{numInput(s.boardsPerMonth, (n) => patch({ boardsPerMonth: n }))}</label>
|
||||||
|
<label className="space-y-1"><span className={label}>Rent / month</span>{numInput(s.rent, (n) => patch({ rent: n }))}</label>
|
||||||
|
<label className="space-y-1"><span className={label}>Utilities / month</span>{numInput(s.utilities, (n) => patch({ utilities: n }))}</label>
|
||||||
|
<label className="space-y-1"><span className={label}>Subscriptions / month</span>{numInput(s.subscriptions, (n) => patch({ subscriptions: n }))}</label>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">Per board: <span className="font-bold text-gray-300">{money(r.operations)}</span></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Materials */}
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Materials</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead><tr><th className={th}>Material</th><th className={th}>Order ($)</th><th className={th}>Ship ($)</th><th className={th}># boards</th><th className={th}>Order (m)</th><th className={th}>Unload (m)</th><th className={th}>Per board</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{s.materials.map((m) => {
|
||||||
|
const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0;
|
||||||
|
const up = (p: Partial<MaterialRow>) => patch({ materials: s.materials.map((x) => x.id === m.id ? { ...x, ...p } : x) });
|
||||||
|
return (
|
||||||
|
<tr key={m.id} className="border-t border-white/5">
|
||||||
|
<td className={td}><Input value={m.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
|
||||||
|
<td className={td}>{numInput(m.orderCost, (n) => up({ orderCost: n }))}</td>
|
||||||
|
<td className={td}>{numInput(m.shipping, (n) => up({ shipping: n }))}</td>
|
||||||
|
<td className={td}>{numInput(m.boards, (n) => up({ boards: n }))}</td>
|
||||||
|
<td className={td}>{numInput(m.orderMin, (n) => up({ orderMin: n }))}</td>
|
||||||
|
<td className={td}>{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}</td>
|
||||||
|
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(per)}</td>
|
||||||
|
<td className={td}><button onClick={() => patch({ materials: s.materials.filter((x) => x.id !== m.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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Processes */}
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Process costs</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead><tr><th className={th}>Process</th><th className={th}>Time (m)</th><th className={th}># boards</th><th className={th}>Per board</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{s.processes.map((p) => {
|
||||||
|
const per = p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0;
|
||||||
|
const up = (pp: Partial<ProcessRow>) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) });
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="border-t border-white/5">
|
||||||
|
<td className={td}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
|
||||||
|
<td className={td}>{numInput(p.minutes, (n) => up({ minutes: n }))}</td>
|
||||||
|
<td className={td}>{numInput(p.boards, (n) => up({ boards: n }))}</td>
|
||||||
|
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(per)}</td>
|
||||||
|
<td className={td}><button onClick={() => patch({ processes: s.processes.filter((x) => x.id !== p.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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Packaging */}
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">Packaging</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead><tr><th className={th}>Packaging</th><th className={th}>Time/board (m)</th><th className={th}>Materials/board ($)</th><th className={th}>Per board</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{s.packaging.map((p) => {
|
||||||
|
const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin;
|
||||||
|
const up = (pp: Partial<PackageRow>) => patch({ packaging: s.packaging.map((x) => x.id === p.id ? { ...x, ...pp } : x) });
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="border-t border-white/5">
|
||||||
|
<td className={td}><Input value={p.name} onChange={(e) => up({ name: e.target.value })} className="h-8 min-w-36" /></td>
|
||||||
|
<td className={td}>{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}</td>
|
||||||
|
<td className={td}>{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}</td>
|
||||||
|
<td className={`${td} whitespace-nowrap font-bold text-gray-300`}>{money(per)}</td>
|
||||||
|
<td className={td}><button onClick={() => patch({ packaging: s.packaging.filter((x) => x.id !== p.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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Margins + results */}
|
||||||
|
<section className="rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/[0.04] p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-black uppercase tracking-wide text-[#ccff00]">Margins</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<label className="space-y-1"><span className={label}>Profit margin (×)</span>{numInput(s.profitMargin, (n) => patch({ profitMargin: n }))}</label>
|
||||||
|
<label className="space-y-1"><span className={label}>Scrap (%)</span>{numInput(s.scrapPct, (n) => patch({ scrapPct: n }))}</label>
|
||||||
|
<div className="space-y-1"><span className={label}>Cost / board (no packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{money(r.costNoPack)}</div></div>
|
||||||
|
<div className="space-y-1"><span className={label}>Cost / board (with packaging)</span><div className="flex h-8 items-center text-sm font-bold text-gray-200">{money(r.costWithPack)}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · no packaging</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-[#ccff00]">{money(r.priceNoPack)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[#ccff00]/40 bg-black/40 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">Selling price · with packaging</p>
|
||||||
|
<p className="mt-1 text-2xl font-black text-[#ccff00]">{money(r.priceWithPack)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -794,6 +794,11 @@ diyUi: {
|
||||||
title: 'DIY — Make Your Own',
|
title: 'DIY — Make Your Own',
|
||||||
tagline: 'Guides, open tools and plans to build your own decks, ramps and concrete spots — then find the people, places and market to go with them.',
|
tagline: 'Guides, open tools and plans to build your own decks, ramps and concrete spots — then find the people, places and market to go with them.',
|
||||||
doItWithPeople: 'Do it with people',
|
doItWithPeople: 'Do it with people',
|
||||||
|
backToDiy: 'Back to DIY',
|
||||||
|
calc: {
|
||||||
|
title: 'Deck cost calculator',
|
||||||
|
intro: 'What a deck costs to make — labour, equipment, materials, process time, packaging and margin — and a fair selling price. Saved on this device.',
|
||||||
|
},
|
||||||
source: 'Source',
|
source: 'Source',
|
||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
footer: 'Step-by-step guides are on the way. For now: find the people who build in The Lab, map your spot, and buy or sell in the Marketplace. Work safely — pads, gloves and eye protection.',
|
footer: 'Step-by-step guides are on the way. For now: find the people who build in The Lab, map your spot, and buy or sell in the Marketplace. Work safely — pads, gloves and eye protection.',
|
||||||
|
|
|
||||||
|
|
@ -794,6 +794,11 @@ diyUi: {
|
||||||
title: 'DIY — Hazlo tú mismo',
|
title: 'DIY — Hazlo tú mismo',
|
||||||
tagline: 'Guías, herramientas abiertas y planos para hacer tus propias tablas, rampas y spots de hormigón — y luego encontrar a la gente, los sitios y el mercado.',
|
tagline: 'Guías, herramientas abiertas y planos para hacer tus propias tablas, rampas y spots de hormigón — y luego encontrar a la gente, los sitios y el mercado.',
|
||||||
doItWithPeople: 'Hazlo con gente',
|
doItWithPeople: 'Hazlo con gente',
|
||||||
|
backToDiy: 'Volver a DIY',
|
||||||
|
calc: {
|
||||||
|
title: 'Calculadora de coste de tabla',
|
||||||
|
intro: 'Lo que cuesta hacer una tabla — mano de obra, equipo, materiales, tiempo de proceso, embalaje y margen — y un precio de venta justo. Se guarda en este dispositivo.',
|
||||||
|
},
|
||||||
source: 'Fuente',
|
source: 'Fuente',
|
||||||
comingSoon: 'Próximamente',
|
comingSoon: 'Próximamente',
|
||||||
footer: 'Las guías paso a paso están en camino. Por ahora: encuentra a quien construye en The Lab, mapea tu spot y compra o vende en el Marketplace. Trabaja con seguridad: protecciones, guantes y gafas.',
|
footer: 'Las guías paso a paso están en camino. Por ahora: encuentra a quien construye en The Lab, mapea tu spot y compra o vende en el Marketplace. Trabaja con seguridad: protecciones, guantes y gafas.',
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export const DIY_CARDS: DiyCard[] = [
|
||||||
emoji: '💰',
|
emoji: '💰',
|
||||||
titleKey: 'diyUi.cards.deckCost.title',
|
titleKey: 'diyUi.cards.deckCost.title',
|
||||||
blurbKey: 'diyUi.cards.deckCost.blurb',
|
blurbKey: 'diyUi.cards.deckCost.blurb',
|
||||||
comingSoon: true,
|
to: '/diy/deck-cost',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Ramps & wooden obstacles ─────────────────────────────────────────────
|
// ── Ramps & wooden obstacles ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
34
src/pages/DeckCostPage.tsx
Normal file
34
src/pages/DeckCostPage.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Calculator } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { DeckCostCalculator } from '@/components/DeckCostCalculator';
|
||||||
|
|
||||||
|
/** DIY tool page — the self-contained deck cost calculator. */
|
||||||
|
export default function DeckCostPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Deck Cost Calculator — SkateSpots DIY',
|
||||||
|
description: 'Work out what a deck costs to make — labour, equipment, materials, process time, packaging and margin — and a fair selling price.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<Link to="/diy" className="mb-4 inline-flex items-center text-sm text-muted-foreground transition-colors hover:text-foreground">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('diyUi.backToDiy')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl md:text-3xl font-black uppercase tracking-tight text-white">
|
||||||
|
<Calculator className="h-6 w-6 text-[#ccff00]" />
|
||||||
|
{t('diyUi.calc.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-gray-400">{t('diyUi.calc.intro')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DeckCostCalculator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -75,20 +75,6 @@ export default function DiyPage() {
|
||||||
<p className="mt-2 text-sm text-gray-400 max-w-2xl">{t('diyUi.tagline')}</p>
|
<p className="mt-2 text-sm text-gray-400 max-w-2xl">{t('diyUi.tagline')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Category chips */}
|
|
||||||
<nav className="sticky top-14 md:top-16 z-20 -mx-4 mb-6 flex gap-2 overflow-x-auto border-b border-white/5 bg-black/90 px-4 py-2.5 backdrop-blur scrollbar-hide">
|
|
||||||
{DIY_CATEGORIES.map((c) => (
|
|
||||||
<a
|
|
||||||
key={c.id}
|
|
||||||
href={`#${c.id}`}
|
|
||||||
className="flex shrink-0 min-h-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-bold text-gray-300 transition-colors hover:border-[#ccff00]/40 hover:text-white"
|
|
||||||
>
|
|
||||||
<span aria-hidden>{c.emoji}</span>
|
|
||||||
{t(c.titleKey)}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Resource sections */}
|
{/* Resource sections */}
|
||||||
{DIY_CATEGORIES.map((cat) => {
|
{DIY_CATEGORIES.map((cat) => {
|
||||||
const cards = DIY_CARDS.filter((c) => c.category === cat.id);
|
const cards = DIY_CARDS.filter((c) => c.category === cat.id);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue