From b90fef1d2e872f619c0900d72760e994e514b334 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 10:08:01 +0700 Subject: [PATCH] DIY: drop the category chips; add the deck cost calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 8 +- scripts/smoke-test.mjs | 1 + src/AppRouter.tsx | 2 + src/components/DeckCostCalculator.tsx | 306 ++++++++++++++++++++++++++ src/i18n/en.ts | 5 + src/i18n/es.ts | 5 + src/lib/diyGuides.ts | 2 +- src/pages/DeckCostPage.tsx | 34 +++ src/pages/DiyPage.tsx | 14 -- 9 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 src/components/DeckCostCalculator.tsx create mode 100644 src/pages/DeckCostPage.tsx diff --git a/AGENTS.md b/AGENTS.md index ae4a303..bb4d9c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 mobile burger, and is linked from the **site footer** + the **map links strip**. 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 - live, plus the in-app deck cost calculator. + live. - **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be paid in **sats only (Lightning or Cashu)** — no cards/accounts — sold **locally, on request**: there is **no cart, no checkout, no shipping**. Buying diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 064771f..037524d 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -146,6 +146,7 @@ const ROUTES = [ '/mints', '/app-shop', '/diy', + '/diy/deck-cost', '/review', '/card', '/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index b253b88..aef3c2d 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -19,6 +19,7 @@ const WalletPage = lazyWithRetry(() => import("./pages/WalletPage")); const MintsPage = lazyWithRetry(() => import("./pages/MintsPage")); const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage")); const DiyPage = lazyWithRetry(() => import("./pages/DiyPage")); +const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage")); const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage")); const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); @@ -143,6 +144,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx new file mode 100644 index 0000000..db87e34 --- /dev/null +++ b/src/components/DeckCostCalculator.tsx @@ -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('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) => setS((prev) => ({ ...prev, ...p })); + + const numInput = (value: number, onChange: (n: number) => void, className?: string) => ( + 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 ( +
+ {/* Labor */} +
+

Labor

+
+ + +
+ Labor cost +
+ {money(s.hourlyRate * s.overheadFactor)}/hr +
+
+
+
+ + {/* Equipment */} +
+

Equipment

+
+ + + + {s.equipment.map((e) => ( + + + + + + + + + ))} + +
EquipmentCost ($)Setup (h)# boardsPer board
patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" />{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) }))}{money(e.boards > 0 ? e.cost / e.boards : 0)}
+
+ +
+ + {/* Operations */} +
+

Operation expenses

+
+ + + + +
+

Per board: {money(r.operations)}

+
+ + {/* Materials */} +
+

Materials

+
+ + + + {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) => patch({ materials: s.materials.map((x) => x.id === m.id ? { ...x, ...p } : x) }); + return ( + + + + + + + + + + + ); + })} + +
MaterialOrder ($)Ship ($)# boardsOrder (m)Unload (m)Per board
up({ name: e.target.value })} className="h-8 min-w-36" />{numInput(m.orderCost, (n) => up({ orderCost: n }))}{numInput(m.shipping, (n) => up({ shipping: n }))}{numInput(m.boards, (n) => up({ boards: n }))}{numInput(m.orderMin, (n) => up({ orderMin: n }))}{numInput(m.unloadMin, (n) => up({ unloadMin: n }))}{money(per)}
+
+ +
+ + {/* Processes */} +
+

Process costs

+
+ + + + {s.processes.map((p) => { + const per = p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0; + const up = (pp: Partial) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); + return ( + + + + + + + + ); + })} + +
ProcessTime (m)# boardsPer board
up({ name: e.target.value })} className="h-8 min-w-36" />{numInput(p.minutes, (n) => up({ minutes: n }))}{numInput(p.boards, (n) => up({ boards: n }))}{money(per)}
+
+ +
+ + {/* Packaging */} +
+

Packaging

+
+ + + + {s.packaging.map((p) => { + const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin; + const up = (pp: Partial) => patch({ packaging: s.packaging.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); + return ( + + + + + + + + ); + })} + +
PackagingTime/board (m)Materials/board ($)Per board
up({ name: e.target.value })} className="h-8 min-w-36" />{numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))}{numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))}{money(per)}
+
+ +
+ + {/* Margins + results */} +
+

Margins

+
+ + +
Cost / board (no packaging)
{money(r.costNoPack)}
+
Cost / board (with packaging)
{money(r.costWithPack)}
+
+ +
+
+

Selling price · no packaging

+

{money(r.priceNoPack)}

+
+
+

Selling price · with packaging

+

{money(r.priceWithPack)}

+
+
+

Does not include shipping to the buyer.

+
+ + +
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1b01f91..5e450c9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -794,6 +794,11 @@ diyUi: { 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.', 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', 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.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 8163743..0d1bf0a 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -794,6 +794,11 @@ diyUi: { 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.', 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', 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.', diff --git a/src/lib/diyGuides.ts b/src/lib/diyGuides.ts index 2e299d9..c765fe1 100644 --- a/src/lib/diyGuides.ts +++ b/src/lib/diyGuides.ts @@ -63,7 +63,7 @@ export const DIY_CARDS: DiyCard[] = [ emoji: '💰', titleKey: 'diyUi.cards.deckCost.title', blurbKey: 'diyUi.cards.deckCost.blurb', - comingSoon: true, + to: '/diy/deck-cost', }, // ── Ramps & wooden obstacles ───────────────────────────────────────────── diff --git a/src/pages/DeckCostPage.tsx b/src/pages/DeckCostPage.tsx new file mode 100644 index 0000000..85a7cfb --- /dev/null +++ b/src/pages/DeckCostPage.tsx @@ -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 ( +
+ + + {t('diyUi.backToDiy')} + + +
+

+ + {t('diyUi.calc.title')} +

+

{t('diyUi.calc.intro')}

+
+ + +
+ ); +} diff --git a/src/pages/DiyPage.tsx b/src/pages/DiyPage.tsx index 20e9deb..9c473d7 100644 --- a/src/pages/DiyPage.tsx +++ b/src/pages/DiyPage.tsx @@ -75,20 +75,6 @@ export default function DiyPage() {

{t('diyUi.tagline')}

- {/* Category chips */} - - {/* Resource sections */} {DIY_CATEGORIES.map((cat) => { const cards = DIY_CARDS.filter((c) => c.category === cat.id);