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