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:
SkateSpots 2026-10-05 10:08:01 +07:00
parent 30d46ddfed
commit b90fef1d2e
9 changed files with 360 additions and 17 deletions

View file

@ -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

View file

@ -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',

View file

@ -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 />} />

View 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>
);
}

View file

@ -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.',

View file

@ -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.',

View file

@ -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 ─────────────────────────────────────────────

View 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>
);
}

View file

@ -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);