Initial commit: OSSM Configurator with share and export functionality

This commit is contained in:
MunchDev-oss
2026-01-04 16:29:29 -05:00
commit 9b6424dfa1
58 changed files with 11434 additions and 0 deletions

161
website/src/App.jsx Normal file
View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react';
import MainPage from './components/MainPage';
import Wizard from './components/Wizard';
import partsData from './data/index.js';
import { getSharedConfig } from './utils/shareService';
function App() {
const [buildType, setBuildType] = useState(null);
const [config, setConfig] = useState({
motor: null,
powerSupply: null,
primaryColor: 'black',
accentColor: 'black',
mount: null,
cover: null,
standHinge: null,
standFeet: null,
standCrossbarSupports: [],
toyMountOptions: [],
toyMounts: [],
actuatorMount: null,
standParts: [],
pcbMount: null,
});
// Check for share link on load
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const shareId = urlParams.get('share');
const isSession = urlParams.get('session') === 'true';
if (shareId) {
const sharedConfig = getSharedConfig(shareId, isSession);
if (sharedConfig) {
setConfig(sharedConfig);
setBuildType('self-source'); // Default build type for shared configs
// Clean up the URL
window.history.replaceState({}, document.title, window.location.pathname);
} else {
alert('This share link has expired or is invalid. Share links are valid for 7 days.');
}
}
}, []);
const handleSelectBuildType = (type) => {
if (type === 'rad-kit') {
// Pre-select RAD kit parts
const radKitConfig = getRADKitConfig();
setConfig(radKitConfig);
setBuildType('rad-kit');
} else {
setBuildType(type);
}
};
const getRADKitConfig = () => {
// Standard RAD kit configuration
// Assuming the kit includes:
// - 57AIM30 motor (recommended)
// - 24V 5A PSU
// - Standard mount (middle-pivot or pitclamp - using middle-pivot as default)
// - Standard colors (black/black)
// - Basic stand components
// - Default toy mount options (flange mount base)
const motor = partsData.motors.find(m => m.id === '57AIM30') || partsData.motors[0];
const powerSupply = partsData.powerSupplies.find(ps => ps.id === 'psu-24v-5a') || partsData.powerSupplies[0];
// Get mount from options data to ensure proper structure
const mountOptions = partsData.options?.actuator?.sections?.mounts?.options || [];
const mount = mountOptions.find(m => m.id === 'middle-pivot') || mountOptions[0] || null;
// Get stand hinge (default: pivot-plate)
const hingeOptions = partsData.options?.stand?.sections?.hinges?.options || [];
const standHinge = hingeOptions.find(h => h.id === 'pivot-plate') || hingeOptions[0] || null;
// Get stand feet (default: standard-feet)
const feetOptions = partsData.options?.stand?.sections?.feet?.options || [];
const standFeet = feetOptions.find(f => f.id === 'standard-feet') || feetOptions[0] || null;
// Get stand crossbar supports (default: standard-90-degree-support)
const crossbarSupportOptions = partsData.options?.stand?.sections?.crossbarSupports?.options || [];
const standCrossbarSupports = [];
const standardSupport = crossbarSupportOptions.find(s => s.id === 'standard-90-degree-support') || crossbarSupportOptions[0];
if (standardSupport) {
standCrossbarSupports.push(standardSupport);
}
// Get default toy mount options (flange mount base - first option)
const toyMountOptions = [];
const flangeMountOptions = partsData.options?.toyMounts?.sections?.flangeMount?.options || [];
if (flangeMountOptions.length > 0) {
// Select the first option (flange-base-24mm-threaded)
toyMountOptions.push(flangeMountOptions[0]);
}
// Get cover (default: standard-cover)
const coverOptions = partsData.options?.actuator?.sections?.cover?.options || [];
const cover = coverOptions.find(c => c.id === 'standard-cover') || coverOptions[0] || null;
// Get PCB mount (default: 3030-mount)
const pcbMountOptions = partsData.options?.actuator?.sections?.pcbMount?.options || [];
const pcbMount = pcbMountOptions.find(p => p.id === '3030-mount') || pcbMountOptions[0] || null;
return {
motor,
powerSupply,
primaryColor: 'black',
accentColor: 'black',
mount,
cover,
standHinge,
standFeet,
standCrossbarSupports,
toyMountOptions,
toyMounts: [],
actuatorMount: null,
standParts: [],
pcbMount,
};
};
const updateConfig = (updates) => {
setConfig((prev) => ({ ...prev, ...updates }));
};
const handleBackToMain = () => {
setBuildType(null);
setConfig({
motor: null,
powerSupply: null,
primaryColor: 'black',
accentColor: 'black',
mount: null,
cover: null,
standHinge: null,
standFeet: null,
standCrossbarSupports: [],
toyMountOptions: [],
toyMounts: [],
actuatorMount: null,
standParts: [],
pcbMount: null,
});
};
if (!buildType) {
return <MainPage onSelectBuildType={handleSelectBuildType} />;
}
return (
<Wizard
buildType={buildType}
initialConfig={config}
updateConfig={updateConfig}
onBackToMain={handleBackToMain}
/>
);
}
export default App;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
import partsData from '../data/index.js';
export default function MainPage({ onSelectBuildType }) {
const handleSelect = (buildType) => {
onSelectBuildType(buildType);
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="mb-12 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
OSSM Configurator
</h1>
<p className="text-gray-600">
Configure your Open Source Sex Machine
</p>
</div>
{/* Build Type Selection */}
<div className="bg-white rounded-lg shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
Select Your Build Type
</h2>
<div className="grid md:grid-cols-3 gap-6">
{/* New Build - RAD Kit */}
<button
onClick={() => handleSelect('rad-kit')}
className="flex flex-col items-center p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all duration-200 group"
>
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-4 group-hover:bg-blue-200 transition-colors">
<svg
className="w-8 h-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
New Build
</h3>
<p className="text-sm font-medium text-blue-600 mb-3">
Kit from RAD
</p>
<p className="text-sm text-gray-600 text-center">
Pre-configured kit with all required parts. Jump straight to the summary.
</p>
</button>
{/* New Build - Self Source */}
<button
onClick={() => handleSelect('self-source')}
className="flex flex-col items-center p-6 border-2 border-gray-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-all duration-200 group"
>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4 group-hover:bg-green-200 transition-colors">
<svg
className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
New Build
</h3>
<p className="text-sm font-medium text-green-600 mb-3">
Self Source
</p>
<p className="text-sm text-gray-600 text-center">
Go through the full wizard to select and customize all components.
</p>
</button>
{/* Upgrade */}
<button
onClick={() => handleSelect('upgrade')}
className="flex flex-col items-center p-6 border-2 border-gray-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-all duration-200 group"
>
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mb-4 group-hover:bg-purple-200 transition-colors">
<svg
className="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Upgrade / Mod
</h3>
<p className="text-sm font-medium text-purple-600 mb-3">
Add Modifications
</p>
<p className="text-sm text-gray-600 text-center">
Browse and select upgrade components and modifications for your existing build.
</p>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,302 @@
import { useState, useEffect } from 'react';
import MotorStep from './steps/MotorStep';
import PowerSupplyStep from './steps/PowerSupplyStep';
import ColorsStep from './steps/ColorsStep';
import OptionsStep from './steps/OptionsStep';
import RemoteStep from './steps/RemoteStep';
import ToyMountStep from './steps/ToyMountStep';
import BOMSummary from './BOMSummary';
const steps = [
{ id: 'motor', name: 'Motor', component: MotorStep },
{ id: 'powersupply', name: 'Power Supply', component: PowerSupplyStep },
{ id: 'colors', name: 'Colors', component: ColorsStep },
{ id: 'options', name: 'Options', component: OptionsStep },
{ id: 'remote', name: 'Remote', component: RemoteStep },
{ id: 'toymounts', name: 'Toy Mounts', component: ToyMountStep },
{ id: 'summary', name: 'Summary', component: BOMSummary },
];
export default function Wizard({ buildType = 'self-source', initialConfig, updateConfig: updateConfigProp, onBackToMain }) {
// For RAD Kit, start at Remote step (index 4)
const getInitialStep = () => {
if (buildType === 'rad-kit') {
// Remote step is at index 4 (Motor=0, PowerSupply=1, Colors=2, Options=3, Remote=4, ToyMounts=5, Summary=6)
return 4;
}
return 0;
};
const [currentStep, setCurrentStep] = useState(getInitialStep());
const [config, setConfig] = useState(initialConfig || {
motor: '57AIM30',
powerSupply: '24V PSU',
primaryColor: 'black',
accentColor: 'black',
mount: 'Middle Pivot',
cover: 'Simple',
standHinge: 'Pivot Plate',
standFeet: '3030 Extrusion',
standCrossbarSupports: 'standard',
pcbMount: null,
});
useEffect(() => {
if (initialConfig) {
setConfig(initialConfig);
}
}, [initialConfig]);
const updateConfig = (updates) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
if (updateConfigProp) {
updateConfigProp(newConfig);
}
};
// Filter steps for upgrade mode - skip motor and power supply, start with options
const getFilteredSteps = () => {
if (buildType === 'upgrade') {
// For upgrade mode, only show options and summary
return [
{ id: 'options', name: 'Upgrade Options', component: OptionsStep },
{ id: 'summary', name: 'Summary', component: BOMSummary },
];
}
return steps;
};
const filteredSteps = getFilteredSteps();
const nextStep = () => {
// In upgrade mode, no validation needed
if (buildType === 'upgrade') {
if (currentStep < filteredSteps.length - 1) {
setCurrentStep(currentStep + 1);
}
return;
}
// Validate required selections before moving to next step
if (currentStep === 0 && !config.motor) {
// Motor step - require motor selection
return;
}
if (currentStep === 1 && !config.powerSupply) {
// Power Supply step - require power supply selection
return;
}
if (currentStep < filteredSteps.length - 1) {
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const canNavigateToStep = (stepIndex) => {
// Can always go back or stay on current step
if (stepIndex <= currentStep) {
return true;
}
// In upgrade mode, no validation needed
if (buildType === 'upgrade') {
return true;
}
// In RAD Kit mode, all steps are pre-selected, so navigation is always allowed
if (buildType === 'rad-kit') {
return true;
}
// Check if required steps are completed before jumping ahead
if (stepIndex > 0 && !config.motor) {
return false; // Can't skip motor step
}
if (stepIndex > 1 && !config.powerSupply) {
return false; // Can't skip power supply step
}
return true;
};
const goToStep = (stepIndex) => {
if (stepIndex >= 0 && stepIndex < filteredSteps.length && canNavigateToStep(stepIndex)) {
setCurrentStep(stepIndex);
}
};
const canProceedToNextStep = () => {
// In upgrade mode, no validation needed
if (buildType === 'upgrade') {
return true;
}
if (currentStep === 0 && !config.motor) {
return false; // Motor step - require motor selection
}
if (currentStep === 1 && !config.powerSupply) {
return false; // Power Supply step - require power supply selection
}
return true;
};
const CurrentStepComponent = filteredSteps[currentStep].component;
// Adjust current step if we're in upgrade mode
useEffect(() => {
if (buildType === 'upgrade' && currentStep >= 2) {
// Skip to options step (index 0 in filtered steps)
setCurrentStep(0);
}
}, [buildType, currentStep]);
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
{onBackToMain && (
<div className="mb-4">
<button
onClick={onBackToMain}
className="text-blue-600 hover:text-blue-800 font-medium flex items-center gap-2"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Back to Main Menu
</button>
</div>
)}
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
OSSM Configurator
</h1>
<p className="text-gray-600">
{buildType === 'upgrade'
? 'Select upgrade components and modifications'
: 'Configure your Open Source Sex Machine'}
</p>
{buildType === 'upgrade' && (
<div className="mt-4 bg-purple-50 border border-purple-200 rounded-lg p-3 inline-block">
<p className="text-purple-800 text-sm font-medium">
Upgrade Mode: Only modification and upgrade components are shown
</p>
</div>
)}
</div>
{/* Step Indicator */}
<div className="mb-8">
<div className="flex items-start justify-between relative">
{filteredSteps.map((step, index) => (
<div key={step.id} className="flex flex-col items-center flex-1 relative">
{/* Circle */}
<button
onClick={() => goToStep(index)}
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold flex-shrink-0 z-10 ${
index === currentStep
? 'bg-blue-600 text-white'
: index < currentStep
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-500'
} ${
index <= currentStep
? 'cursor-pointer hover:opacity-80'
: 'cursor-not-allowed'
}`}
disabled={!canNavigateToStep(index)}
>
{index < currentStep ? '✓' : index + 1}
</button>
{/* Connecting line to the right */}
{index < filteredSteps.length - 1 && (
<div
className={`absolute top-5 left-1/2 h-1 ${
index < currentStep ? 'bg-green-500' : 'bg-gray-200'
}`}
style={{
width: 'calc(100% - 40px)',
marginLeft: '20px'
}}
/>
)}
{/* Text label */}
<button
onClick={() => goToStep(index)}
className={`mt-2 text-sm font-medium text-center ${
index <= currentStep
? 'text-blue-600 cursor-pointer hover:text-blue-800'
: 'text-gray-400 cursor-not-allowed'
}`}
disabled={!canNavigateToStep(index)}
>
{step.name}
</button>
</div>
))}
</div>
</div>
{/* Step Content */}
<div className="bg-white rounded-lg shadow-lg p-6 md:p-8 mb-6">
<CurrentStepComponent
config={config}
updateConfig={updateConfig}
nextStep={nextStep}
prevStep={prevStep}
buildType={buildType}
/>
</div>
{/* Navigation Buttons */}
{currentStep < filteredSteps.length - 1 && (
<div className="flex justify-between">
<button
onClick={prevStep}
disabled={currentStep === 0}
className={`px-6 py-2 rounded-lg font-medium ${
currentStep === 0
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-gray-600 text-white hover:bg-gray-700'
}`}
>
Previous
</button>
<div className="flex gap-2">
<button
onClick={nextStep}
disabled={!canProceedToNextStep()}
className={`px-6 py-2 rounded-lg font-medium ${
canProceedToNextStep()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import partsData from '../../data/index.js';
export default function ColorsStep({ config, updateConfig }) {
const handlePrimaryColorSelect = (color) => {
updateConfig({ primaryColor: color.id });
};
const handleAccentColorSelect = (color) => {
updateConfig({ accentColor: color.id });
};
return (
<div>
<h2 className="text-2xl font-bold mb-4">Select Colors</h2>
<p className="text-gray-600 mb-6">
Choose primary and accent colors for your OSSM build.
</p>
<div className="space-y-8">
{/* Primary Color */}
<div>
<h3 className="text-xl font-semibold mb-4">Primary Color</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
{partsData.colors.primary.map((color) => (
<button
key={color.id}
onClick={() => handlePrimaryColorSelect(color)}
className={`flex flex-col items-center p-4 border-2 rounded-lg transition-all ${
config.primaryColor === color.id
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div
className="w-16 h-16 rounded-full mb-2 border-2 border-gray-300"
style={{ backgroundColor: color.hex }}
/>
<span className="text-sm font-medium text-gray-700">
{color.name}
</span>
{config.primaryColor === color.id && (
<div className="mt-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</button>
))}
</div>
</div>
{/* Accent Color */}
<div>
<h3 className="text-xl font-semibold mb-4">Accent Color</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
{partsData.colors.accent.map((color) => (
<button
key={color.id}
onClick={() => handleAccentColorSelect(color)}
className={`flex flex-col items-center p-4 border-2 rounded-lg transition-all ${
config.accentColor === color.id
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div
className="w-16 h-16 rounded-full mb-2 border-2 border-gray-300"
style={{ backgroundColor: color.hex }}
/>
<span className="text-sm font-medium text-gray-700">
{color.name}
</span>
{config.accentColor === color.id && (
<div className="mt-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</button>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
export default function MotorStep({ config, updateConfig }) {
const selectedMotorId = config.motor?.id;
const handleSelect = (motor) => {
updateConfig({ motor });
};
const recommendedMotors = partsData.motors.filter(m => m.recommended);
const otherMotors = partsData.motors.filter(m => !m.recommended);
const hasSingleRecommended = recommendedMotors.length === 1;
const renderMotorCard = (motor, isRecommended = false, isSlightlyLarger = false) => (
<button
key={motor.id}
onClick={() => handleSelect(motor)}
className={`${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${
selectedMotorId === motor.id
? 'border-blue-600 bg-blue-50 shadow-lg'
: motor.recommended
? 'border-green-500 bg-green-50 hover:border-green-600 hover:bg-green-100'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{motor.recommended && (
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex items-center px-3 py-1 text-xs font-semibold text-green-800 bg-green-200 rounded-full">
Recommended
</span>
</div>
)}
{motor.image && (
<div className={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}>
<img
src={motor.image}
alt={motor.name}
className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100`}
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<div className="flex items-start justify-between mb-2">
<h3 className={`${isSlightlyLarger ? 'text-lg' : 'text-base'} font-semibold text-gray-900`}>
{motor.name}
</h3>
{selectedMotorId === motor.id && (
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
<svg
className="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</div>
<p className={`${isSlightlyLarger ? 'text-sm' : 'text-sm'} text-gray-600 mb-3`}>{motor.description}</p>
<div className={`flex ${isSlightlyLarger ? 'gap-4' : 'gap-3'} text-sm`}>
<div>
<span className="text-gray-500">Speed:</span>{' '}
<span className="font-medium">{motor.speed}</span>
</div>
<div>
<span className="text-gray-500">Wattage:</span>{' '}
<span className="font-medium">{motor.wattage}</span>
</div>
<div>
<span className="text-gray-500">Gear Count:</span>{' '}
<span className="font-medium">{motor.gear_count}</span>
</div>
</div>
<div className={`${isSlightlyLarger ? 'mt-3' : 'mt-2'} flex items-center justify-between`}>
<div className={`${isSlightlyLarger ? 'text-lg' : 'text-lg'} font-bold text-blue-600`}>
{formatPrice(motor.price)}
</div>
</div>
{motor.links && motor.links.length > 0 && (
<div className={`${isSlightlyLarger ? 'mt-3' : 'mt-2'} pt-3 border-t border-gray-200`}>
<p className="text-xs text-gray-500 mb-2">Buy from:</p>
<div className="flex flex-wrap gap-2">
{motor.links.map((link, index) => (
<a
key={index}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 hover:text-blue-800 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<svg
className="w-3 h-3 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
{link.store}
</a>
))}
</div>
</div>
)}
</button>
);
return (
<div>
<h2 className="text-2xl font-bold mb-4">Select Motor</h2>
<p className="text-gray-600 mb-6">
Choose the stepper motor for your OSSM build.
</p>
{/* Recommended Motor(s) */}
{recommendedMotors.length > 0 && (
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center' : ''}`}>
{hasSingleRecommended ? (
<div className="w-full max-w-md">
{renderMotorCard(recommendedMotors[0], true, true)}
</div>
) : (
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-700">Recommended Options</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recommendedMotors.map((motor) => renderMotorCard(motor, true, false))}
</div>
</div>
)}
</div>
)}
{/* Other Motors - Smaller Grid */}
{otherMotors.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-700">Other Options</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{otherMotors.map((motor) => renderMotorCard(motor, false, false))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,446 @@
import { useState, useEffect } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
export default function OptionsStep({ config, updateConfig, buildType }) {
const [expandedMainSections, setExpandedMainSections] = useState({});
const [expandedSubSections, setExpandedSubSections] = useState({});
const handleMountSelect = (option) => {
updateConfig({ mount: option });
setExpandedSubSections((prev) => ({ ...prev, 'actuator.mounts': false }));
};
const handleCoverSelect = (option) => {
updateConfig({ cover: option });
setExpandedSubSections((prev) => ({ ...prev, 'actuator.cover': false }));
};
const handlePcbMountSelect = (option) => {
updateConfig({ pcbMount: option });
setExpandedSubSections((prev) => ({ ...prev, 'actuator.pcbMount': false }));
};
const handleStandHingeSelect = (option) => {
updateConfig({ standHinge: option });
setExpandedSubSections((prev) => ({ ...prev, 'stand.hinges': false }));
};
const handleStandFeetSelect = (option) => {
updateConfig({ standFeet: option });
setExpandedSubSections((prev) => ({ ...prev, 'stand.feet': false }));
};
const handleStandCrossbarSupportToggle = (option) => {
const currentSupports = config.standCrossbarSupports || [];
const isSelected = currentSupports.some((opt) => opt.id === option.id);
if (isSelected) {
updateConfig({
standCrossbarSupports: currentSupports.filter((opt) => opt.id !== option.id),
});
} else {
updateConfig({
standCrossbarSupports: [...currentSupports, option],
});
}
};
const toggleMainSection = (mainSectionId) => {
setExpandedMainSections((prev) => ({
...prev,
[mainSectionId]: !prev[mainSectionId],
}));
};
const toggleSubSection = (subSectionKey) => {
setExpandedSubSections((prev) => ({
...prev,
[subSectionKey]: !prev[subSectionKey],
}));
};
const getSelectedOptionsForSubSection = (mainSectionId, subSectionId, subSection = null) => {
const key = `${mainSectionId}.${subSectionId}`;
switch (key) {
case 'actuator.mounts':
return config.mount ? [config.mount] : [];
case 'actuator.cover':
return config.cover ? [config.cover] : [];
case 'actuator.pcbMount':
return config.pcbMount ? [config.pcbMount] : [];
case 'stand.hinges':
return config.standHinge ? [config.standHinge] : [];
case 'stand.feet':
return config.standFeet ? [config.standFeet] : [];
case 'stand.crossbarSupports':
return config.standCrossbarSupports || [];
default:
return [];
}
};
const isOptionSelected = (option, mainSectionId, subSectionId, subSection = null) => {
const selected = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
return selected.some((opt) => opt.id === option.id);
};
const isMainSectionComplete = (mainSectionId, mainSection) => {
const subSections = Object.entries(mainSection.sections || {});
// Check if all sub-sections with options are complete
for (const [subSectionId, subSection] of subSections) {
// Skip if no options available
if (!subSection.options || subSection.options.length === 0) {
continue;
}
const selectedOptions = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
// All sub-sections (both single-select and multi-select) require at least one selection
// Multi-select means you can select multiple items, but you still need at least one
if (selectedOptions.length === 0) {
return false;
}
}
return true;
};
// Auto-collapse main sections when they become complete
useEffect(() => {
const mainSections = partsData.options ? Object.entries(partsData.options) : [];
mainSections.forEach(([mainSectionId, mainSection]) => {
// Skip toyMounts and remoteControl sections (now in their own steps)
if (mainSectionId === 'toyMounts' || mainSectionId === 'remoteControl') {
return;
}
if (isMainSectionComplete(mainSectionId, mainSection)) {
setExpandedMainSections((prev) => {
// Only auto-collapse if the section is currently expanded (undefined or true)
// Don't override if user has explicitly collapsed it (false)
const currentlyExpanded = prev[mainSectionId] !== false;
if (currentlyExpanded) {
return { ...prev, [mainSectionId]: false };
}
return prev;
});
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.mount, config.cover, config.pcbMount, config.standHinge, config.standFeet, config.standCrossbarSupports]);
const handleOptionClick = (option, mainSectionId, subSectionId) => {
const key = `${mainSectionId}.${subSectionId}`;
switch (key) {
case 'actuator.mounts':
handleMountSelect(option);
break;
case 'actuator.cover':
handleCoverSelect(option);
break;
case 'actuator.pcbMount':
handlePcbMountSelect(option);
break;
case 'stand.hinges':
handleStandHingeSelect(option);
break;
case 'stand.feet':
handleStandFeetSelect(option);
break;
case 'stand.crossbarSupports':
handleStandCrossbarSupportToggle(option);
break;
default:
break;
}
};
const renderOptionCard = (option, mainSectionId, subSectionId, subSection = null, isMultiSelect = false) => {
const isSelected = isOptionSelected(option, mainSectionId, subSectionId, subSection);
return (
<button
key={option.id}
onClick={() => handleOptionClick(option, mainSectionId, subSectionId)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{option.image && (
<div className="mb-3 flex justify-center">
<img
src={option.image}
alt={option.name}
className="h-48 w-48 object-contain rounded-lg bg-gray-100"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">
{option.name}
</h4>
{option.description && (
<p className="text-sm text-gray-600">{option.description}</p>
)}
</div>
{isSelected && (
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
{isMultiSelect ? (
<span className="text-white text-sm font-bold"></span>
) : (
<svg
className="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm mt-3">
{option.filamentEstimate && (
<div>
<span className="text-gray-500">Filament:</span>{' '}
<span className="font-medium">{option.filamentEstimate}</span>
</div>
)}
{option.hardwareCost !== undefined && (
<div>
<span className="text-gray-500">Hardware:</span>{' '}
<span className="font-medium">
{formatPrice(option.hardwareCost)}
</span>
</div>
)}
</div>
</button>
);
};
const renderSubSection = (mainSectionId, subSectionId, subSection) => {
const subSectionKey = `${mainSectionId}.${subSectionId}`;
const selectedOptions = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
const hasSelection = selectedOptions.length > 0;
const isExpanded = expandedSubSections[subSectionKey] !== false && (!hasSelection || expandedSubSections[subSectionKey] === true);
return (
<div key={subSectionId} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => toggleSubSection(subSectionKey)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-3">
<h4 className="font-semibold text-gray-800">{subSection.title}</h4>
{hasSelection && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="text-sm text-gray-600">
{selectedOptions.map((opt) => opt.name).join(', ')}
</span>
</div>
)}
</div>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${
isExpanded ? 'transform rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isExpanded && subSection.options && subSection.options.length > 0 && (
<div className="p-4 bg-white">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{subSection.options.map((option) =>
renderOptionCard(option, mainSectionId, subSectionId, subSection, subSection.isMultiSelect)
)}
</div>
</div>
)}
</div>
);
};
const renderMainSection = (mainSectionId, mainSection) => {
const isExpanded = expandedMainSections[mainSectionId] !== false;
const subSections = Object.entries(mainSection.sections || {});
const isComplete = isMainSectionComplete(mainSectionId, mainSection);
return (
<div key={mainSectionId} className={`border-2 rounded-lg overflow-hidden mb-4 ${
isComplete ? 'border-green-500' : 'border-gray-300'
}`}>
<button
onClick={() => toggleMainSection(mainSectionId)}
className={`w-full px-6 py-4 transition-colors flex items-center justify-between ${
isComplete
? 'bg-green-50 hover:bg-green-100'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
<div className="flex items-center gap-3">
<h3 className={`text-xl font-bold ${
isComplete ? 'text-green-900' : 'text-gray-900'
}`}>
{mainSection.title}
</h3>
{isComplete && (
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
<svg
className="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="text-sm font-medium text-green-700">Complete</span>
</div>
)}
</div>
<svg
className={`w-6 h-6 transition-transform ${
isExpanded ? 'transform rotate-180' : ''
} ${isComplete ? 'text-green-600' : 'text-gray-600'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isExpanded && (
<div className="p-4 space-y-4 bg-white">
{subSections.map(([subSectionId, subSection]) =>
renderSubSection(mainSectionId, subSectionId, subSection)
)}
</div>
)}
</div>
);
};
const mainSections = partsData.options ? Object.entries(partsData.options) : [];
// Filter sections and options for upgrade mode
const getFilteredSections = () => {
if (buildType !== 'upgrade') {
return mainSections;
}
// In upgrade mode, only show sections with mod components
return mainSections
.map(([mainSectionId, mainSection]) => {
// Filter sub-sections to only show those with mod options
const filteredSubSections = {};
Object.entries(mainSection.sections || {}).forEach(([subSectionId, subSection]) => {
// Check if this sub-section has mod options
const hasModOptions = subSection.options?.some(opt => opt.type === 'mod') ||
subSection.componentType === 'mod';
if (hasModOptions) {
// Filter options to only show mods
const modOptions = subSection.options?.filter(opt => opt.type === 'mod') || [];
if (modOptions.length > 0 || subSection.componentType === 'mod') {
filteredSubSections[subSectionId] = {
...subSection,
options: modOptions.length > 0 ? modOptions : subSection.options,
};
}
}
});
// Only include main section if it has any filtered sub-sections
if (Object.keys(filteredSubSections).length > 0) {
return [mainSectionId, { ...mainSection, sections: filteredSubSections }];
}
return null;
})
.filter(Boolean);
};
const filteredSections = getFilteredSections();
// Filter out toyMounts and remoteControl sections (now in their own steps)
const sectionsToRender = filteredSections.filter(([mainSectionId]) => mainSectionId !== 'toyMounts' && mainSectionId !== 'remoteControl');
return (
<div>
<h2 className="text-2xl font-bold mb-4">
{buildType === 'upgrade' ? 'Select Upgrades & Modifications' : 'Select Options'}
</h2>
<p className="text-gray-600 mb-6">
{buildType === 'upgrade'
? 'Choose upgrade components and modifications for your existing build.'
: 'Choose your preferred mounting options and accessories.'}
</p>
{sectionsToRender.length === 0 && buildType === 'upgrade' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800">
No upgrade components available. All components are base modules.
</p>
</div>
)}
<div className="space-y-4">
{sectionsToRender.map(([mainSectionId, mainSection]) =>
renderMainSection(mainSectionId, mainSection)
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
export default function PowerSupplyStep({ config, updateConfig }) {
const selectedPowerSupplyId = config.powerSupply?.id;
const selectedMotorId = config.motor?.id;
const handleSelect = (powerSupply) => {
updateConfig({ powerSupply });
};
// Filter compatible power supplies
const compatiblePowerSupplies = partsData.powerSupplies.filter((psu) => {
if (!selectedMotorId) return true;
return psu.compatibleMotors.includes(selectedMotorId);
});
return (
<div>
<h2 className="text-2xl font-bold mb-4">Select Power Supply</h2>
<p className="text-gray-600 mb-6">
Choose a compatible power supply for your selected motor.
</p>
{selectedMotorId && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
Showing power supplies compatible with:{' '}
<span className="font-semibold">
{config.motor?.name || 'Selected Motor'}
</span>
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{compatiblePowerSupplies.map((powerSupply) => (
<button
key={powerSupply.id}
onClick={() => handleSelect(powerSupply)}
className={`p-6 border-2 rounded-lg text-left transition-all ${
selectedPowerSupplyId === powerSupply.id
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{powerSupply.image && (
<div className="mb-4 flex justify-center">
<img
src={powerSupply.image}
alt={powerSupply.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-gray-900">
{powerSupply.name}
</h3>
{selectedPowerSupplyId === powerSupply.id && (
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
<svg
className="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</div>
<p className="text-sm text-gray-600 mb-3">
{powerSupply.description}
</p>
<div className="flex gap-4 text-sm">
<div>
<span className="text-gray-500">Voltage:</span>{' '}
<span className="font-medium">{powerSupply.voltage}</span>
</div>
<div>
<span className="text-gray-500">Current:</span>{' '}
<span className="font-medium">{powerSupply.current}</span>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-lg font-bold text-blue-600">
{formatPrice(powerSupply.price)}
</div>
</div>
{powerSupply.links && powerSupply.links.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500 mb-2">Buy from:</p>
<div className="flex flex-wrap gap-2">
{powerSupply.links.map((link, index) => (
<a
key={index}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 hover:text-blue-800 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<svg
className="w-3 h-3 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
{link.store}
</a>
))}
</div>
</div>
)}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
export default function RemoteStep({ config, updateConfig, buildType }) {
const [expandedKnobs, setExpandedKnobs] = useState(false);
const availableRemotes = [
{
id: 'ossm-remote-standard',
name: 'OSSM - Remote',
description: 'Standard OSSM remote (can be purchased from RAD or self-sourced with PCB Way)',
radrOnly: false,
},
{
id: 'ossm-remote-radr',
name: 'OSSM - RADR',
description: 'RADR remote system (RAD only)',
radrOnly: true,
},
];
// Show all available remotes for both build types
const getAvailableRemotes = () => {
return availableRemotes;
};
const selectedRemoteId = config.remoteType || config.remote?.id;
const selectedRemotePCB = config.remotePCB || null;
const handleRemoteSelect = (remoteId) => {
// Consolidate all updates into a single call to prevent state issues
const updates = {
remoteType: remoteId,
};
// Reset PCB selection when switching remotes
if (remoteId === 'ossm-remote-radr') {
// RADR only available from RAD
updates.remotePCB = 'rad';
} else {
updates.remotePCB = null;
}
// Clear knob selection when switching remotes
updates.remoteKnob = null;
updateConfig(updates);
};
const handlePCBSelect = (source) => {
updateConfig({ remotePCB: source });
};
const handleKnobSelect = (knob) => {
updateConfig({ remoteKnob: knob });
};
const getSelectedRemoteSystem = () => {
if (!selectedRemoteId) return null;
return partsData.components?.remotes?.systems?.[selectedRemoteId] || null;
};
const getAvailableKnobs = () => {
const remoteSystem = getSelectedRemoteSystem();
if (!remoteSystem || !remoteSystem.knobs) return [];
return remoteSystem.knobs.map((knob) => ({
id: knob.id,
name: knob.name,
description: knob.description,
filamentEstimate: knob.filamentEstimate !== undefined ? `~${knob.filamentEstimate}g` : "0g",
timeEstimate: knob.timeEstimate,
colour: knob.colour,
}));
};
const isKnobSelected = (knobId) => {
return config.remoteKnob?.id === knobId;
};
const renderRemoteCard = (remote) => {
const isSelected = selectedRemoteId === remote.id;
const remoteSystem = partsData.components?.remotes?.systems?.[remote.id];
const imagePath = remoteSystem?.image ? `${remoteSystem.image}` : null;
return (
<button
key={remote.id}
onClick={() => handleRemoteSelect(remote.id)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
{imagePath && (
<div className="mb-3 flex justify-center">
<img
src={imagePath}
alt={remote.name}
className="h-48 w-48 object-contain rounded-lg bg-gray-100"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">
{remote.name}
</h4>
<p className="text-sm text-gray-600">{remote.description}</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
<svg
className="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</div>
</button>
);
};
const renderPCBSelection = () => {
if (!selectedRemoteId || selectedRemoteId === 'ossm-remote-radr') {
// RADR only available from RAD, so no selection needed
return null;
}
return (
<div className="mt-4 mb-6">
<h3 className="text-lg font-semibold mb-3">PCB Purchase Source</h3>
<div className="flex gap-4">
<button
onClick={() => handlePCBSelect('rad')}
className={`px-4 py-2 border-2 rounded-lg transition-all ${
selectedRemotePCB === 'rad'
? 'border-blue-600 bg-blue-50 text-blue-900 font-medium'
: 'border-gray-200 hover:border-gray-300'
}`}
>
Purchase from RAD
</button>
<button
onClick={() => handlePCBSelect('pcbway')}
className={`px-4 py-2 border-2 rounded-lg transition-all ${
selectedRemotePCB === 'pcbway'
? 'border-blue-600 bg-blue-50 text-blue-900 font-medium'
: 'border-gray-200 hover:border-gray-300'
}`}
>
Self-source with PCBWay
</button>
</div>
</div>
);
};
const renderKnobCard = (knob) => {
const isSelected = isKnobSelected(knob.id);
return (
<button
key={knob.id}
onClick={() => handleKnobSelect(knob)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">
{knob.name}
</h4>
{knob.description && (
<p className="text-sm text-gray-600">{knob.description}</p>
)}
</div>
{isSelected && (
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
<svg
className="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm mt-3">
{knob.filamentEstimate && (
<div>
<span className="text-gray-500">Filament:</span>{' '}
<span className="font-medium">{knob.filamentEstimate}</span>
</div>
)}
</div>
</button>
);
};
const availableRemotesFiltered = getAvailableRemotes();
const availableKnobs = getAvailableKnobs();
const hasRemoteSelected = !!selectedRemoteId;
const hasKnobSelected = !!config.remoteKnob;
// Auto-expand knobs section when remote is selected
useEffect(() => {
if (hasRemoteSelected && availableKnobs.length > 0) {
setExpandedKnobs(true);
}
}, [hasRemoteSelected, availableKnobs.length]);
return (
<div>
<h2 className="text-2xl font-bold mb-4">Select Remote Control</h2>
<p className="text-gray-600 mb-6">
Choose your remote control system and knob option.
</p>
{/* Remote Selection */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">Remote System</h3>
<div className="grid grid-cols-2 gap-4">
{availableRemotesFiltered.map((remote) => renderRemoteCard(remote))}
</div>
</div>
{/* PCB Purchase Source (only for OSSM - Remote) */}
{hasRemoteSelected && renderPCBSelection()}
{/* Knobs Selection (only shown when remote is selected) */}
{hasRemoteSelected && availableKnobs.length > 0 && (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedKnobs(!expandedKnobs)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-800">Remote Knobs</h3>
{hasKnobSelected && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="text-sm text-gray-600">
{config.remoteKnob?.name}
</span>
</div>
)}
</div>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${
expandedKnobs ? 'transform rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{expandedKnobs && (
<div className="p-4 bg-white">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{availableKnobs.map((knob) => renderKnobCard(knob))}
</div>
</div>
)}
</div>
)}
{!hasRemoteSelected && (
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-yellow-800 text-sm">
<strong>Note:</strong> Please select a remote control system to continue.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
export default function ToyMountStep({ config, updateConfig }) {
const [expandedSubSections, setExpandedSubSections] = useState({});
const handleToyMountToggle = (option) => {
const currentToyMounts = config.toyMountOptions || [];
const isSelected = currentToyMounts.some((opt) => opt.id === option.id);
if (isSelected) {
updateConfig({
toyMountOptions: currentToyMounts.filter((opt) => opt.id !== option.id),
});
} else {
updateConfig({
toyMountOptions: [...currentToyMounts, option],
});
}
};
const toggleSubSection = (subSectionKey) => {
setExpandedSubSections((prev) => ({
...prev,
[subSectionKey]: !prev[subSectionKey],
}));
};
const getSelectedOptionsForSubSection = (subSectionId, subSection) => {
const allToyMountOptions = config.toyMountOptions || [];
const subsectionOptionIds = new Set(subSection.options.map(opt => opt.id));
return allToyMountOptions.filter(opt => subsectionOptionIds.has(opt.id));
};
const isOptionSelected = (option, subSectionId, subSection) => {
const selected = getSelectedOptionsForSubSection(subSectionId, subSection);
return selected.some((opt) => opt.id === option.id);
};
const handleOptionClick = (option, subSectionId, subSection) => {
handleToyMountToggle(option);
};
const renderOptionCard = (option, subSectionId, subSection) => {
const isSelected = isOptionSelected(option, subSectionId, subSection);
return (
<button
key={option.id}
onClick={() => handleOptionClick(option, subSectionId, subSection)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div className="flex items-start gap-4">
{option.image && (
<div className="flex-shrink-0">
<img
src={option.image}
alt={option.name}
className="h-24 w-24 object-contain rounded-lg bg-gray-100"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">
{option.name}
</h4>
{option.description && (
<p className="text-sm text-gray-600">{option.description}</p>
)}
</div>
{isSelected && (
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
<span className="text-white text-sm font-bold"></span>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm mt-3">
{option.filamentEstimate && (
<div>
<span className="text-gray-500">Filament:</span>{' '}
<span className="font-medium">{option.filamentEstimate}</span>
</div>
)}
{option.hardwareCost !== undefined && (
<div>
<span className="text-gray-500">Hardware:</span>{' '}
<span className="font-medium">
{formatPrice(option.hardwareCost)}
</span>
</div>
)}
</div>
</div>
</div>
</button>
);
};
const renderSubSection = (subSectionId, subSection) => {
const subSectionKey = `toyMounts.${subSectionId}`;
const selectedOptions = getSelectedOptionsForSubSection(subSectionId, subSection);
const hasSelection = selectedOptions.length > 0;
const isExpanded = expandedSubSections[subSectionKey] !== false && (!hasSelection || expandedSubSections[subSectionKey] === true);
return (
<div key={subSectionId} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => toggleSubSection(subSectionKey)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-3">
<h4 className="font-semibold text-gray-800">{subSection.title}</h4>
{hasSelection && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="text-sm text-gray-600">
{selectedOptions.map((opt) => opt.name).join(', ')}
</span>
</div>
)}
</div>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${
isExpanded ? 'transform rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isExpanded && subSection.options && subSection.options.length > 0 && (
<div className="p-4 space-y-3 bg-white">
{subSection.options.map((option) =>
renderOptionCard(option, subSectionId, subSection)
)}
</div>
)}
</div>
);
};
const toyMountsSection = partsData.options?.toyMounts;
const subSections = toyMountsSection ? Object.entries(toyMountsSection.sections || {}) : [];
const hasSelection = (config.toyMountOptions || []).length > 0;
return (
<div>
<h2 className="text-2xl font-bold mb-4">Select Toy Mounts</h2>
<p className="text-gray-600 mb-6">
Choose your preferred toy mount options. You can select multiple options from different categories.
</p>
{subSections.length > 0 && (
<div className="space-y-4">
{subSections.map(([subSectionId, subSection]) =>
renderSubSection(subSectionId, subSection)
)}
</div>
)}
{!hasSelection && (
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-yellow-800 text-sm">
<strong>Note:</strong> At least one toy mount option is recommended for your build.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
{
"primary": [
{
"id": "black",
"name": "Black",
"hex": "#000000"
},
{
"id": "white",
"name": "White",
"hex": "#FFFFFF"
},
{
"id": "red",
"name": "Red",
"hex": "#EF4444"
},
{
"id": "blue",
"name": "Blue",
"hex": "#3B82F6"
},
{
"id": "purple",
"name": "Purple",
"hex": "#A855F7"
},
{
"id": "pink",
"name": "Pink",
"hex": "#EC4899"
}
],
"accent": [
{
"id": "black",
"name": "Black",
"hex": "#000000"
},
{
"id": "white",
"name": "White",
"hex": "#FFFFFF"
},
{
"id": "red",
"name": "Red",
"hex": "#EF4444"
},
{
"id": "blue",
"name": "Blue",
"hex": "#3B82F6"
},
{
"id": "purple",
"name": "Purple",
"hex": "#A855F7"
},
{
"id": "pink",
"name": "Pink",
"hex": "#EC4899"
},
{
"id": "gold",
"name": "Gold",
"hex": "#F59E0B"
},
{
"id": "silver",
"name": "Silver",
"hex": "#9CA3AF"
}
]
}

View File

@@ -0,0 +1,29 @@
{
"rules": [
{
"type": "mountToStandCrossbar",
"description": "Only 1 mount option can be selected",
"check": "mountOptions.length === 1"
},
{
"type": "hingeToStandCrossbar",
"description": "Only 1 hinge option can be selected",
"check": "hingeOptions.length === 1"
},
{
"type": "standCrossbarSupportToStandCrossbar",
"description": "Only 1 stand crossbar support option can be selected",
"check": "standCrossbarSupportOptions.length === 1"
}
],
"requiredParts": {
"motor": true,
"powerSupply": true,
"actuatorMount": true,
"pcbMount": true
},
"optionalParts": {
"toyMounts": false,
"standParts": false
}
}

View File

@@ -0,0 +1,188 @@
{
"actuator": {
"category": "Actuator",
"type": "base",
"printedParts": [
{
"id": "ossm-actuator-body-bottom",
"name": "Actuator Bottom",
"description": "Actuator bottom part",
"filamentEstimate": 56.7,
"timeEstimate": "2h14m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Actuator Body Bottom.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Bottom.stl?raw=true"
},
{
"id": "ossm-actuator-body-middle",
"name": "Actuator Middle",
"description": "Actuator middle part",
"filamentEstimate": 65.69,
"timeEstimate": "2h23m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Actuator Body Middle.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Middle.stl?raw=true"
},
{
"id": "ossm-actuator-body-cover",
"name": "Actuator Cover",
"description": "Actuator cover part",
"filamentEstimate": 27.61,
"timeEstimate": "1h3m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Actuator Body Cover.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Cover.stl?raw=true"
},
{
"id": "ossm-belt-tensioner",
"name": "Belt Tensioner",
"description": "Belt tensioner part",
"filamentEstimate": 10.51,
"timeEstimate": "40m25s",
"colour": "secondary",
"required": true,
"filePath": "OSSM - Belt Tensioner.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Belt%20Tensioner.stl?raw=true"
},
{
"id": "ossm-24mm-clamping-thread-belt-clamp",
"name": "24mm Clamping Thread Belt Clamp",
"description": "24mm clamping thread part",
"filamentEstimate": 2.01,
"timeEstimate": "19m36s",
"colour": "secondary",
"required": true,
"filePath": "OSSM - 24mm Clamping Thread Belt Clamp.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20Belt%20Clamp.stl?raw=true"
},
{
"id": "ossm-24mm-clamping-thread-end-effector",
"name": "24mm Clamping Thread End Effector",
"description": "24mm clamping thread end effector part",
"filamentEstimate": 18.52,
"timeEstimate": "1h20m",
"colour": "secondary",
"required": true,
"filePath": "OSSM - 24mm Clamping Thread End Effector.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20End%20Effector.stl?raw=true"
},
{
"id": "ossm-24mm-nut-5-sided",
"name": "24mm Nut 5 Sided",
"description": "24mm nut 5 sided part",
"filamentEstimate": 5.12,
"timeEstimate": "21m10s",
"colour": "secondary",
"required": true,
"filePath": "OSSM - 24mm Nut 5 Sided.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Nut%20-%205%20Sided.stl?raw=true"
}
],
"hardwareParts": [
{
"id": "hardware-fasteners-m3x8-shcs",
"required": true,
"quantity": 8,
"relatedParts": [
"ossm-actuator-body-bottom"
]
},
{
"id": "hardware-fasteners-m3x16-shcs",
"required": true,
"quantity": 2,
"relatedParts": [
"ossm-24mm-nut-6-sided"
]
},
{
"id": "hardware-fasteners-m3x20-shcs",
"required": true,
"quantity": 1,
"relatedParts": [
"ossm-24mm-nut-5-sided"
]
},
{
"id": "hardware-fasteners-m3-hex-nut",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-24mm-nut-hex"
]
},
{
"id": "hardware-fasteners-m5-hex-nut",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-actuator-body-bottom"
]
},
{
"id": "hardware-fasteners-m5x20-shcs",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-actuator-body-bottom",
"ossm-actuator-body-middle",
"ossm-actuator-body-middle-pivot"
]
},
{
"id": "hardware-fasteners-m5x35-shcs",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-24mm-nut-shcs"
]
},
{
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-24mm-nut-hex"
]
},
{
"id": "hardware-gt2-pulley",
"required": true,
"quantity": 1,
"relatedParts": [
"ossm-actuator-body-bottom"
]
},
{
"id": "hardware-gt2-belt",
"required": true,
"quantity": 1,
"relatedParts": [
"ossm-24mm-clamping-thread-belt-clamp",
"ossm-belt-tensioner"
]
},
{
"id": "hardware-mgn12h-linear-rail",
"required": true,
"quantity": 1,
"relatedParts": [
"ossm-gt2-belt-clamp",
"ossm-24mm-nut-shcs",
"ossm-actuator-body-bottom"
]
},
{
"id": "hardware-bearing-MR115-2RS 5x11x4mm",
"required": true,
"quantity": 6,
"relatedParts": [
"ossm-actuator-body-bottom"
]
}
]
}
}

View File

@@ -0,0 +1,138 @@
{
"pitClamp": {
"category": "PitClamp",
"type": "base",
"printedParts": [
{
"id": "ossm-pitclamp-mini-lower",
"name": "PitClamp Mini Lower",
"description": "PitClamp mounting system",
"filamentEstimate": 49.45,
"timeEstimate": "1h55m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Base - PitClamp Mini - Lower V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Lower%20V1.1.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-upper",
"name": "PitClamp Mini Upper",
"description": "PitClamp mounting system",
"filamentEstimate": 27.36,
"timeEstimate": "1h11m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Base - PitClamp Mini - Upper V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Upper%20V1.1.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-57AIM30",
"name": "PitClamp Mini 57AIM30",
"description": "PitClamp mounting system",
"filamentEstimate": 46.03,
"timeEstimate": "2h10m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Base - PitClamp Mini - 57AIM30 V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2057AIM%20V1.1.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-42AIM30",
"name": "PitClamp Mini 42AIM30",
"description": "PitClamp mounting system",
"filamentEstimate": 46.03,
"timeEstimate": "2h10m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Base - PitClamp Mini - 42AIM30 V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2042AIM%20V1.1.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-iHSV57",
"name": "PitClamp Mini iHSV57",
"description": "PitClamp mounting system",
"filamentEstimate": 46.03,
"timeEstimate": "2h10m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Base - PitClamp Mini - iHSV57 V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%20iHSV57.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-handle",
"name": "PitClamp Mini Handle",
"description": "PitClamp mounting system",
"filamentEstimate": 9.23,
"timeEstimate": "2h10m",
"colour": "secondary",
"required": true,
"filePath": "OSSM - Handle - PitClamp Mini V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Handle.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-dogbone-nuts",
"name": "PitClamp Mini Dogbone Nuts",
"description": "PitClamp mounting system",
"filamentEstimate": 4.44,
"timeEstimate": "20m49s",
"colour": "secondary",
"required": true,
"quantity": 2,
"filePath": "OSSM - Dogbone Nuts - PitClamp Mini V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Nuts.stl?raw=true"
},
{
"id": "ossm-pitclamp-mini-dogbone-bolts ",
"name": "PitClamp Mini Dogbone Bolts",
"description": "PitClamp mounting system",
"filamentEstimate": 4.44,
"timeEstimate": "20m49s",
"colour": "secondary",
"required": true,
"quantity": 2,
"filePath": "OSSM - Dogbone Bolts - PitClamp Mini V1.1.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Bolts.stl?raw=true"
}
],
"hardwareParts": [
{
"id": "pitclamp-hardware",
"required": true
}
]
},
"middlePivot": {
"category": "Middle Pivot",
"type": "base",
"printedParts": [
{
"id": "ossm-actuator-body-middle-pivot",
"name": "Actuator Body Middle Pivot",
"description": "Middle Pivot mounting system",
"filamentEstimate": 147.19,
"timeEstimate": "5h8m",
"colour": "primary",
"required": true,
"filePath": "OSSM - Actuator Body Middle Pivot.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/Non-standard/OSSM%20-%20Actuator%20-%20Body%20-%20Middle%20Pivot.stl?raw=true"
},
{
"id": "ossm-handle-spacer",
"name": "Handle Spacer",
"description": "Handle spacer part",
"filamentEstimate": 0,
"colour": "secondary",
"required": true,
"quantity": 2,
"filePath": "OSSM - Handle Spacer.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true"
}
],
"hardwareParts": [
{
"id": "middle-pivot-hardware",
"required": true
}
]
}
}

View File

@@ -0,0 +1,108 @@
{
"remotes": {
"category": "Remote",
"type": "mod",
"systems": {
"ossm-remote-standard": {
"name": "OSSM Remote Standard",
"description": "Standard OSSM remote system",
"image": "/images/remote/standard-remote.png",
"bodyParts": [
{
"id": "ossm-remote-body",
"name": "Remote Body",
"description": "Remote system",
"filamentEstimate": 23.39,
"timeEstimate": "53m42s",
"colour": "primary",
"required": true,
"filePath": "ossm-remote-body.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Body.stl?raw=true"
},
{
"id": "ossm-remote-top-cover",
"name": "Remote Top Cover",
"description": "Remote system",
"filamentEstimate": 12.37,
"timeEstimate": "37m32s",
"colour": "secondary",
"required": true,
"filePath": "ossm-remote-top-cover.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Top%20Cover.stl?raw=true"
}
],
"knobs": [
{
"id": "ossm-remote-knob",
"name": "Remote Knob",
"description": "Remote system",
"filamentEstimate": 20.79,
"timeEstimate": "1h14m",
"colour": "primary",
"required": true,
"filePath": "ossm-remote-knob.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Knob%20-%20Rounded.stl?raw=true"
},
{
"id": "ossm-remote-knob-simple",
"name": "Remote Knob Simple",
"description": "Remote system",
"filamentEstimate": 20.79,
"timeEstimate": "1h14m",
"colour": "primary",
"required": true,
"filePath": "ossm-remote-knob-simple.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/tree/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple.stl?raw=true"
},
{
"id": "ossm-remote-knob-simple-with-position-indicator",
"name": "Remote Knob Simple With Position Indicator",
"description": "Remote system",
"filamentEstimate": 0,
"colour": "primary",
"required": false,
"filePath": "ossm-remote-knob-simple-with-position-indicator.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple%20With%20Position%20Indicator.stl?raw=true"
},
{
"id": "ossm-remote-knob-knurled",
"name": "Remote Knob Knurled",
"description": "Remote system",
"filamentEstimate": 0,
"colour": "primary",
"required": false,
"filePath": "ossm-remote-knob-knurled.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled.stl?raw=true"
},
{
"id": "ossm-remote-knob-knurled-with-position-indicator",
"name": "Remote Knob Knurled With Position Indicator",
"description": "Remote system",
"filamentEstimate": 0,
"colour": "primary",
"required": false,
"filePath": "ossm-remote-knob-knurled-with-position-indicator.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled%20With%20Position%20Indicator.stl?raw=true"
}
],
"hardwareParts": [
{
"id": "remote-hardware",
"required": true
}
]
},
"ossm-remote-radr": {
"name": "OSSM - RADR",
"description": "RADR remote system (RAD only)",
"image": "/images/remote/radr-remote.png",
"hardwareParts": [
{
"id": "remote-hardware",
"required": true
}
]
}
}
}
}

View File

@@ -0,0 +1,212 @@
{
"hinges": {
"category": "Hinges",
"type": "mod",
"systems": {
"pivot-plate": {
"name": "Pivot Plate",
"description": "Pivot plate for the stand",
"image": "/images/options/pivot-plate.webp",
"hardwareCost": 10,
"price": 0,
"printedParts": [
{
"id": "pivot-plate",
"name": "Pivot Plate Left",
"description": "Pivot plate for the stand",
"filamentEstimate": 150,
"colour": "primary",
"required": true,
"filePath": "OSSM - Stand - Pivot Plate.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Left.stl?raw=true"
},
{
"id": "pivot-plate-right",
"name": "Pivot Plate Right",
"description": "Pivot plate for the stand",
"filamentEstimate": 150,
"colour": "primary",
"required": true,
"filePath": "OSSM - Stand - Pivot Plate.stl",
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Right.stl?raw=true"
}
],
"hardwareParts": [
{
"id": "hardware-fasteners-m6x25-shcs",
"required": true,
"quantity": 6
},
{
"id": "hardware-fasteners-m6-t-nuts",
"required": true,
"quantity": 6
},
{
"id": "hardware-fasteners-m6-washer",
"required": true,
"quantity": 6
},
{
"id": "hardware-fasteners-m6x25-handle",
"required": true,
"quantity": 2
}
]
},
"pitclamp-reinforced-3030": {
"name": "PitClamp Reinforced 3030 Hinges",
"description": "Reinforced 3030 hinges for PitClamp",
"image": "/images/options/pitclamp-reinforced-3030-hinges.jpg",
"hardwareCost": 15,
"price": 0,
"printedParts": [
{
"id": "pitclamp-reinforced-3030",
"name": "PitClamp Reinforced 3030 Hinges",
"description": "Reinforced 3030 hinges for PitClamp",
"filamentEstimate": 200,
"colour": "primary",
"required": true
}
],
"hardwareParts": [
{
"id": "pitclamp-reinforced-3030-hardware",
"required": true
}
]
}
}
},
"feet": {
"category": "Feet",
"type": "mod",
"printedParts": [
{
"id": "standard-feet",
"name": "Standard",
"description": "Standard feet",
"filamentEstimate": 50,
"image": "/images/options/standard-feet.jpg",
"hardwareCost": 0,
"price": 0,
"colour": "secondary",
"required": true
},
{
"id": "suction-feet",
"name": "Suction",
"description": "Suction feet for better stability",
"filamentEstimate": 60,
"image": "/images/options/suction-feet.jpg",
"hardwareCost": 5,
"price": 0,
"colour": "secondary",
"required": true
}
],
"hardwareParts": [
{
"id": "hardware-fasteners-m6x12-shcs",
"required": true,
"quantity": 4,
"relatedParts": [
"standard-feet"
]
},
{
"id": "hardware-fasteners-m6-t-nuts",
"required": true,
"quantity": 4,
"relatedParts": [
"standard-feet"
]
}
]
},
"caps": {
"category": "Caps",
"type": "base",
"printedParts": [
{
"id": "ossm-3030-cap",
"name": "3030 Cap",
"description": "Cap mounting system",
"filamentEstimate": 10,
"timeEstimate": "2h10m",
"colour": "secondary",
"required": true,
"filePath": "OSSM - 3030 Cap.stl",
"quantity": 6,
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Extrusion%20Cap.stl?raw=true"
}
]
},
"crossbarSupports": {
"category": "Crossbar Supports",
"type": "mod",
"printedParts": [
{
"id": "standard-90-degree-support",
"name": "Standard 90 Degree Support",
"description": "Standard 90 degree support for the stand (hardware only)",
"filamentEstimate": 0,
"image": "/images/options/standard-90-degree-support.jpg",
"hardwareCost": 10,
"price": "$10.00-$20.00",
"colour": "primary",
"required": true,
"isHardwareOnly": true
},
{
"id": "3d-printed-90-degree-support",
"name": "3D Printed 90 Degree Support",
"description": "3D printed 90 degree support for the stand",
"filamentEstimate": 100,
"image": "/images/options/3d-printed-90-degree-support.jpg",
"hardwareCost": 2,
"price": "$2.00-$4.00",
"colour": "secondary",
"required": true
}
],
"hardwareParts": [
{
"id": "hardware-fasteners-m6x12-shcs",
"required": true,
"quantity": 8,
"relatedParts": [
"3d-printed-90-degree-support",
"standard-90-degree-support"
]
},
{
"id": "hardware-fasteners-m6-t-nuts",
"required": true,
"quantity": 8,
"relatedParts": [
"3d-printed-90-degree-support",
"standard-90-degree-support"
]
},
{
"id": "hardware-fasteners-m6-washer",
"required": true,
"quantity": 8,
"relatedParts": [
"3d-printed-90-degree-support",
"standard-90-degree-support"
]
},
{
"id": "hardware-fasteners-3030-90-degree-support",
"required": true,
"quantity": 4,
"relatedParts": [
"standard-90-degree-support"
]
}
]
}
}

View File

@@ -0,0 +1,115 @@
{
"toyMounts": {
"category": "Toy Mounts",
"type": "mod",
"printedParts": [
{
"id": "ossm-toy-mount-flange-base-24mm-threaded",
"name": "Toy Mount Flange Base 24mm Threaded",
"description": "Toy mount system",
"filamentEstimate": 46.26,
"timeEstimate": "1h48m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-flange-base-24mm-threaded.stl"
},
{
"id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
"name": "Toy Mount Flange Base Dildo Ring 2.5in",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2_5in.stl"
},
{
"id": "ossm-toy-mount-flange-base-dildo-ring-2in",
"name": "Toy Mount Flange Base Dildo Ring 2in",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2in.stl"
},
{
"id": "ossm-toy-mount-double-double-24mm-threaded",
"name": "Toy Mount Double Double 24mm Threaded",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-double-double-24mm-threaded.stl"
},
{
"id": "ossm-toy-mount-double-double-rail-mounted",
"name": "Toy Mount Double Double Rail Mounted",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "primary",
"required": true,
"filePath": "ossm-toy-mount-double-double-rail-mounted.stl"
},
{
"id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
"name": "Toy Mount Sucson Mount Base Plate 24mm Threaded",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded.stl"
},
{
"id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
"name": "Toy Mount Sucson Mount Ring Insert 55mm",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "primary",
"required": true,
"filePath": "ossm-toy-mount-sucson-mount-ring-insert-55mm.stl"
},
{
"id": "ossm-toy-mount-sucson-mount-threaded-ring",
"name": "Toy Mount Sucson Mount Threaded Ring",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-sucson-mount-threaded-ring.stl"
},
{
"id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
"name": "Toy Mount Tie Down and Suction Plate 110mm",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-110mm.stl"
},
{
"id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
"name": "Toy Mount Tie Down and Suction Plate 135mm",
"description": "Toy mount system",
"filamentEstimate": 15.24,
"timeEstimate": "55m",
"colour": "secondary",
"required": true,
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-135mm.stl"
}
],
"hardwareParts": [
{
"id": "toy-mount-hardware",
"required": true,
"relatedParts": []
}
]
}
}

View File

@@ -0,0 +1,148 @@
{
"fasteners": {
"M3x8 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x8-shcs",
"name": "M3x8 SHCS",
"description": "Hardware fasteners m3x8 socket head cap screw",
"price": 0
},
"M3x16 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x16-shcs",
"name": "M3x16 SHCS",
"description": "Hardware fasteners m3x16 socket head cap screw",
"price": 0
},
"M3x20 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x20-shcs",
"name": "M3x20 SHCS",
"description": "m3x20 socket head cap screw",
"price": 0
},
"M3 Hex Nut": {
"id": "hardware-fasteners-m3-hex-nut",
"name": "M3 Hex Nut",
"description": "Hardware fasteners m3 hex nut",
"price": 0
},
"M5 Hex Nut": {
"id": "hardware-fasteners-m5-hex-nut",
"name": "M5 Hex Nut",
"description": "Hardware fasteners m5 hex nut",
"price": 0
},
"M5x20 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x20-shcs",
"name": "M5x20 SHCS",
"description": "Hardware fasteners m5x20 socket head cap screw",
"price": 0
},
"M5x35 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x35-shcs",
"name": "M5x35 SHCS",
"description": "Hardware fasteners m5x35 socket head cap screw",
"price": 0
},
"M5x20mm Hex Coupling Nut": {
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
"name": "M5x20mm Hex Coupling Nut",
"description": "Hardware fasteners m5x20mm hex coupling nut",
"price": 0
},
"M6x12 Socket Head cap Screw": {
"id": "hardware-fasteners-m6x12-shcs",
"name": "M6x12 SHCS",
"description": "Hardware fasteners m6x12 socket head cap screw",
"price": 0
},
"M6x25 Socket Head cap Screw": {
"id": "hardware-fasteners-m6x25-shcs",
"name": "M6x25 SHCS",
"description": "Hardware fasteners m6x25 socket head cap screw",
"price": 0
},
"M6 T Nuts": {
"id": "hardware-fasteners-m6-t-nuts",
"name": "M6 T Nuts",
"description": "Hardware fasteners m6 t nuts",
"price": 0
},
"M6 Washer": {
"id": "hardware-fasteners-m6-washer",
"name": "M6 Washer",
"description": "Hardware fasteners m6 washer",
"price": 0
},
"M6x25 Handle": {
"id": "hardware-fasteners-m6x25-handle",
"name": "M6x25 Handle",
"description": "Hardware fasteners m6x25 handle",
"price": 0
}
},
"motionComponents": {
"GT2 Pulley": {
"id": "hardware-gt2-pulley",
"name": "GT2 Pulley",
"description": "8mm Bore, 20T, 10mm Wide",
"price": 0
},
"GT2 Belt": {
"id": "hardware-gt2-belt",
"name": "GT2 Belt",
"description": "10mm wide, 500mm long",
"price": 0
},
"MGN12H Linear Rail": {
"id": "hardware-mgn12h-linear-rail",
"name": "MGN12H Linear Rail",
"description": "MGN12H Linear Rail, 350mm long [Min 250mm, recommended 350mm, Max 550mm]",
"price": 0
},
"Bearing MR115-2RS": {
"id": "hardware-bearing-MR115-2RS 5x11x4mm",
"name": "Bearing MR115-2RS 5x11x4mm",
"description": "MR115-2RS 5x11x4mm",
"price": 0
}
},
"extrusions": {
"3030 90 Degree Support": {
"id": "hardware-fasteners-3030-90-degree-support",
"name": "3030 90 Degree Support",
"description": "Hardware fasteners 3030 90 degree support",
"price": 0
}
},
"other": {
"Remote Hardware": {
"id": "remote-hardware",
"name": "Remote Hardware",
"description": "Remote hardware",
"price": 0
},
"PitClamp Hardware": {
"id": "pitclamp-hardware",
"name": "PitClamp Hardware",
"description": "PitClamp hardware",
"price": 0
},
"PitClamp Reinforced 3030 Hardware": {
"id": "pitclamp-reinforced-3030-hardware",
"name": "PitClamp Reinforced 3030 Hardware",
"description": "Hardware for PitClamp Reinforced 3030 hinges",
"price": 0
},
"Middle Pivot Hardware": {
"id": "middle-pivot-hardware",
"name": "Middle Pivot Hardware",
"description": "Middle Pivot hardware",
"price": 0
},
"Toy Mount Hardware": {
"id": "toy-mount-hardware",
"name": "Toy Mount Hardware",
"description": "Toy mount hardware",
"price": 0
}
}
}

250
website/src/data/index.js Normal file
View File

@@ -0,0 +1,250 @@
import motors from './motors.json';
import powerSupplies from './powerSupplies.json';
import optionsData from './options.json';
import colors from './colors.json';
import hardwareData from './hardware.json';
import actuatorComponents from './components/actuator.json';
import standComponents from './components/stand.json';
import mountingComponents from './components/mounting.json';
import toyMountsComponents from './components/toyMounts.json';
import remoteComponents from './components/remote.json';
// Create a hardware lookup map from hardware.json
const hardwareLookup = new Map();
Object.values(hardwareData).forEach((category) => {
Object.values(category).forEach((hardware) => {
hardwareLookup.set(hardware.id, hardware);
});
});
// Function to resolve hardware references (IDs) to full hardware definitions
const resolveHardwareReferences = (components) => {
const resolvedComponents = {};
Object.entries(components).forEach(([componentKey, component]) => {
resolvedComponents[componentKey] = { ...component };
// Resolve hardwareParts if they exist
if (component.hardwareParts) {
resolvedComponents[componentKey].hardwareParts = component.hardwareParts.map((hw) => {
// If it's already a full object, return as-is
if (hw.id && hw.name) {
return hw;
}
// If it's a reference (just an ID string or object with hardwareId), resolve it
const hardwareId = typeof hw === 'string' ? hw : hw.hardwareId || hw.id;
const hardwareDef = hardwareLookup.get(hardwareId);
if (!hardwareDef) {
console.warn(`Hardware not found: ${hardwareId}`);
return hw;
}
// Merge the base definition with any overrides (quantity, relatedParts, required, etc.)
return {
...hardwareDef,
...(typeof hw === 'object' ? hw : {}),
id: hardwareDef.id,
name: hardwareDef.name,
description: hardwareDef.description,
price: hardwareDef.price,
// required comes from the component reference, not from hardware.json
};
});
}
// Also resolve hardwareParts in systems
if (component.systems) {
resolvedComponents[componentKey].systems = {};
Object.entries(component.systems).forEach(([systemKey, system]) => {
resolvedComponents[componentKey].systems[systemKey] = { ...system };
if (system.hardwareParts) {
resolvedComponents[componentKey].systems[systemKey].hardwareParts = system.hardwareParts.map((hw) => {
if (hw.id && hw.name) {
return hw;
}
const hardwareId = typeof hw === 'string' ? hw : hw.hardwareId || hw.id;
const hardwareDef = hardwareLookup.get(hardwareId);
if (!hardwareDef) {
console.warn(`Hardware not found: ${hardwareId}`);
return hw;
}
return {
...hardwareDef,
...(typeof hw === 'object' ? hw : {}),
id: hardwareDef.id,
name: hardwareDef.name,
description: hardwareDef.description,
price: hardwareDef.price,
// required comes from the component reference, not from hardware.json
};
});
}
});
}
});
return resolvedComponents;
};
// Combine all component files into a single components object
const rawComponents = {
...actuatorComponents,
...standComponents,
...mountingComponents,
...toyMountsComponents,
...remoteComponents,
};
// Resolve hardware references
const components = resolveHardwareReferences(rawComponents);
// Convert component parts to options format
const convertComponentPartsToOptions = (componentIds, componentData) => {
if (!componentIds || !Array.isArray(componentIds) || componentIds.length === 0) {
return componentIds && Array.isArray(componentIds) ? [] : undefined;
}
// Check if componentData has systems (new structure) or printedParts (old structure)
if (componentData?.systems) {
// Special handling for remotes: flatten knobs from all systems
if (componentData.category === 'Remote' && componentIds.length > 0) {
// This is for remote knobs - flatten all knobs from all systems
const allKnobs = [];
Object.values(componentData.systems).forEach((system) => {
if (system.knobs) {
system.knobs.forEach((knob) => {
if (componentIds.includes(knob.id)) {
allKnobs.push({
id: knob.id,
name: knob.name,
description: knob.description,
image: knob.image || `/images/options/${knob.id}.jpg`,
filamentEstimate: knob.filamentEstimate !== undefined ? `~${knob.filamentEstimate}g` : "0g",
hardwareCost: system.hardwareCost !== undefined ? system.hardwareCost : 0,
price: system.price !== undefined ? system.price : 0,
timeEstimate: knob.timeEstimate,
colour: knob.colour,
type: componentData.type || 'base',
});
}
});
}
});
return allKnobs;
}
// New structure: systems with printedParts and hardwareParts (for hinges, etc.)
return componentIds
.map((systemId) => {
const system = componentData.systems[systemId];
if (!system) {
console.warn(`Component system not found: ${systemId}`);
return null;
}
// Calculate total filament estimate from printed parts
const totalFilament = (system.printedParts || system.bodyParts)?.reduce((sum, part) => {
return sum + (part.filamentEstimate || 0);
}, 0) || 0;
return {
id: systemId,
name: system.name,
description: system.description,
image: system.image || `/images/options/${systemId}.jpg`,
filamentEstimate: totalFilament > 0 ? `~${totalFilament}g` : "0g",
hardwareCost: system.hardwareCost !== undefined ? system.hardwareCost : 0,
price: system.price !== undefined ? system.price : 0,
colour: (system.printedParts || system.bodyParts)?.[0]?.colour || 'primary',
type: componentData.type || 'base',
};
})
.filter((opt) => opt !== null);
}
// Old structure: printedParts array
if (!componentData || !componentData.printedParts) {
console.warn(`Component data not found or missing printedParts/systems`);
return [];
}
return componentIds
.map((componentId) => {
const part = componentData.printedParts.find((p) => p.id === componentId);
if (!part) {
console.warn(`Component part not found: ${componentId}`);
return null;
}
return {
id: part.id,
name: part.name,
description: part.description,
image: part.image || `/images/options/${part.id}.jpg`,
filamentEstimate: part.filamentEstimate !== undefined ? `~${part.filamentEstimate}g` : "0g",
hardwareCost: part.hardwareCost !== undefined ? part.hardwareCost : 0,
price: part.price !== undefined ? part.price : 0,
timeEstimate: part.timeEstimate,
colour: part.colour,
type: componentData.type || 'base',
};
})
.filter((opt) => opt !== null);
};
// Merge component options into options
const processOptions = (options, componentsData) => {
const processedOptions = { ...options };
// Process each option category
Object.keys(processedOptions).forEach((optionKey) => {
const optionCategory = processedOptions[optionKey];
if (!optionCategory || !optionCategory.sections) return;
const sections = { ...optionCategory.sections };
const categoryUseComponents = optionCategory.useComponents;
// Convert component parts to options format for each section
Object.keys(sections).forEach((sectionKey) => {
const section = sections[sectionKey];
// Check if section has componentIds to process
if (section.componentIds !== undefined) {
// Determine which component category to use
const componentKey = section.useComponents || categoryUseComponents;
if (componentKey) {
const componentData = componentsData[componentKey];
const options = convertComponentPartsToOptions(section.componentIds, componentData);
// Store component type info for filtering
section.componentType = componentData?.type || 'base';
section.options = options;
} else {
console.warn(`No useComponents specified for ${optionKey}.${sectionKey}`);
section.options = [];
}
// Clean up temporary properties
delete section.componentIds;
delete section.useComponents;
}
});
processedOptions[optionKey].sections = sections;
if (categoryUseComponents) {
delete processedOptions[optionKey].useComponents;
}
});
return processedOptions;
};
const options = processOptions(optionsData, components);
export default {
motors,
powerSupplies,
options,
colors,
components,
hardware: hardwareData,
};

View File

@@ -0,0 +1,38 @@
[
{
"id": "57AIM30",
"name": "57AIM30 \"Gold Motor\"",
"description": "Standard NEMA 17 stepper motor with 1.8° step angle",
"speed": "1500 RPM",
"wattage": "100W",
"gear_count": "RS485",
"price": "$125-$250",
"image": "/images/motors/57AIM30.png",
"required": true,
"recommended": true
},
{
"id": "42AIM30",
"name": "42AIM30 \"Round Motor\"",
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
"speed": "1500 RPM",
"wattage": "100W",
"gear_count": "RS485",
"price": "$135-$270",
"image": "/images/motors/42AIM30.png",
"required": true,
"recommended": false
},
{
"id": "iHSV57",
"name": "iHSV57 \"Legacy Motor\"",
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
"speed": "3000 RPM",
"wattage": "180W",
"gear_count": "RS485",
"price": "$150-$300",
"image": "/images/motors/iHSV57.png",
"required": true,
"recommended": false
}
]

View File

@@ -0,0 +1,123 @@
{
"actuator": {
"title": "Actuator",
"sections": {
"mounts": {
"title": "Mounts",
"options": [
{
"id": "middle-pivot",
"name": "Middle Pivot",
"description": "Middle Pivot mounting system",
"image": "/images/options/middle-pivot.png",
"filamentEstimate": "~147g",
"type": "base"
},
{
"id": "pitclamp",
"name": "PitClamp Mini",
"description": "PitClamp Mini mounting system",
"image": "/images/options/PitClamp Mini Base.png",
"filamentEstimate": "~137g",
"type": "base"
}
],
"isMultiSelect": false
},
"cover": {
"title": "Cover",
"options": [
{
"id": "standard-cover",
"name": "Standard Cover",
"description": "Standard actuator cover",
"image": null,
"filamentEstimate": "~27.61g",
"type": "base",
"componentId": "ossm-actuator-body-cover"
},
{
"id": "blank-cover",
"name": "Blank Cover",
"description": "Blank cover option",
"image": null,
"filamentEstimate": "0g",
"type": "base"
}
],
"isMultiSelect": false
},
"pcbMount": {
"title": "PCB Mount",
"options": [
{
"id": "3030-mount",
"name": "3030 Mount",
"description": "PCB mount for 3030 extrusion",
"image": null,
"filamentEstimate": null,
"type": "base"
},
{
"id": "aio-cover-mount",
"name": "AIO Cover Mount",
"description": "All-in-one cover mount on the actuator",
"image": null,
"filamentEstimate": null,
"type": "base"
}
],
"isMultiSelect": false
}
}
},
"stand": {
"title": "Stand",
"sections": {
"hinges": {
"title": "Hinges",
"useComponents": "hinges",
"componentIds": ["pivot-plate", "pitclamp-reinforced-3030"],
"isMultiSelect": false
},
"feet": {
"title": "Feet",
"useComponents": "feet",
"componentIds": ["standard-feet", "suction-feet"],
"isMultiSelect": false
},
"crossbarSupports": {
"title": "Crossbar Supports",
"useComponents": "crossbarSupports",
"componentIds": ["standard-90-degree-support", "3d-printed-90-degree-support"],
"isMultiSelect": false
}
}
},
"toyMounts": {
"title": "Toy Mounts",
"useComponents": "toyMounts",
"sections": {
"vacULock": {
"title": "Vac-U-Lock",
"componentIds": ["ossm-toy-mount-double-double-24mm-threaded", "ossm-toy-mount-double-double-rail-mounted"],
"isMultiSelect": true
},
"flangeMount": {
"title": "Flange Mount",
"componentIds": ["ossm-toy-mount-flange-base-24mm-threaded", "ossm-toy-mount-flange-base-dildo-ring-2.5in", "ossm-toy-mount-flange-base-dildo-ring-2in"],
"isMultiSelect": true
},
"suCSOn": {
"title": "SuCSOn",
"componentIds": ["ossm-toy-mount-sucson-mount-base-plate-24mm-threaded", "ossm-toy-mount-sucson-mount-ring-insert-55mm", "ossm-toy-mount-sucson-mount-threaded-ring"],
"isMultiSelect": true
},
"tieDown": {
"title": "TieDown",
"componentIds": ["ossm-toy-mount-tie-down-and-suction-plate-110mm", "ossm-toy-mount-tie-down-and-suction-plate-135mm"],
"isMultiSelect": true
}
}
}
}

View File

@@ -0,0 +1,60 @@
[
{
"id": "psu-24v-5a",
"name": "24V 5A Power Supply",
"description": "24V DC power supply, 5A output",
"voltage": "24V",
"current": "5A",
"price": 20,
"image": "/images/power-supplies/24v-PSU.png",
"compatibleMotors": [
"57AIM30",
"42AIM30",
"iHSV57"
],
"required": true,
"links": [
{
"store": "Amazon",
"link": "https://www.amazon.ca/Adapter-Female-5-5x2-5mm-Printer-Generator/dp/B0CR7DBKX5/ref=sr_1_5?crid=8CCHI94WM1J2&dib=eyJ2IjoiMSJ9.THY1sfJvVZbDjX-py4dIhAQXj69L2lE1OXB-OZijGqhizoxtEtZo3mrvVSGttuDBQXEHAAMoWxabFOZCD_9Drj4m3NxldA6I3NP2YB3LS14b2_uszbzhrCF_Xyu588Mzhuc59YSTgo3hw_uCub4NUFQZP-hGloBM4rXUYSgKsWrT_RL3l4dzQM9aY0QPVuDUbJreMnLwMF_rOkiH9r2-7jKHwDcEoVH8eQ09rVpXVyUqpcStI62_O2Rq17mu_YexGSyz3_9mznJvQlMPgg_DVBFvg69rhvcjbguSMVP8TG8.iVFiqorJkZztDuddLlNrSh0CRknKRiOp2VbJRHl7RRs&dib_tag=se&keywords=USB%2BC%2BTo%2BDC%2B5.5x2.5mm%2BAdapter&qid=1767501555&sprefix=usb%2Bc%2Bto%2Bdc%2B5%2B5x2%2B5mm%2Badapter%2Caps%2C127&sr=8-5&th=1"
},
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/100500312131213.html"
},
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-24v-power-supply"
}
]
},
{
"id": "psu-24v-usbc-pd",
"name": "24v USB-C PD Adapter",
"description": "24V USB-C PD Adapter, Requires 100W+ Power Supply",
"voltage": "24V",
"current": "5A",
"price": 30,
"image": "/images/power-supplies/24v-usbc-pd.png",
"compatibleMotors": [
"57AIM30",
"42AIM30",
"iHSV57"
],
"required": true,
"links": [
{
"store": "Amazon",
"link": "https://www.amazon.ca/Adapter-Female-5-5x2-5mm-Printer-Generator/dp/B0CR7DBKX5/ref=sr_1_5?crid=8CCHI94WM1J2&dib=eyJ2IjoiMSJ9.THY1sfJvVZbDjX-py4dIhAQXj69L2lE1OXB-OZijGqhizoxtEtZo3mrvVSGttuDBQXEHAAMoWxabFOZCD_9Drj4m3NxldA6I3NP2YB3LS14b2_uszbzhrCF_Xyu588Mzhuc59YSTgo3hw_uCub4NUFQZP-hGloBM4rXUYSgKsWrT_RL3l4dzQM9aY0QPVuDUbJreMnLwMF_rOkiH9r2-7jKHwDcEoVH8eQ09rVpXVyUqpcStI62_O2Rq17mu_YexGSyz3_9mznJvQlMPgg_DVBFvg69rhvcjbguSMVP8TG8.iVFiqorJkZztDuddLlNrSh0CRknKRiOp2VbJRHl7RRs&dib_tag=se&keywords=USB%2BC%2BTo%2BDC%2B5.5x2.5mm%2BAdapter&qid=1767501555&sprefix=usb%2Bc%2Bto%2Bdc%2B5%2B5x2%2B5mm%2Badapter%2Caps%2C127&sr=8-5&th=1"
},
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/100500312131213.html"
},
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-24v-usb-c-adapter"
}
]
}
]

12
website/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
website/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,295 @@
import * as XLSX from 'xlsx';
// Generate markdown overview
export const generateMarkdownOverview = (config, printedParts, hardwareParts, filamentTotals, totalTime, total) => {
const md = [];
md.push('# OSSM Build Configuration');
md.push(`\n**Generated:** ${new Date().toLocaleString()}\n`);
// Motor
if (config.motor) {
md.push(`## Motor: ${config.motor.name}`);
md.push(`- **Price:** ${config.motor.price}`);
md.push(`- **Speed:** ${config.motor.speed}`);
md.push(`- **Wattage:** ${config.motor.wattage}`);
md.push('');
}
// Power Supply
if (config.powerSupply) {
md.push(`## Power Supply: ${config.powerSupply.name}`);
md.push(`- **Price:** ${config.powerSupply.price}`);
md.push('');
}
// Colors
md.push(`## Colors`);
md.push(`- **Primary:** ${config.primaryColor || 'Not selected'}`);
md.push(`- **Accent:** ${config.accentColor || 'Not selected'}`);
md.push('');
// Mount
if (config.mount) {
md.push(`## Mount: ${config.mount.name}`);
md.push('');
}
// Cover
if (config.cover) {
md.push(`## Cover: ${config.cover.name}`);
md.push('');
}
// PCB Mount
if (config.pcbMount) {
md.push(`## PCB Mount: ${config.pcbMount.name}`);
md.push('');
}
// Stand Options
if (config.standHinge || config.standFeet || config.standCrossbarSupports?.length > 0) {
md.push(`## Stand Options`);
if (config.standHinge) md.push(`- **Hinges:** ${config.standHinge.name}`);
if (config.standFeet) md.push(`- **Feet:** ${config.standFeet.name}`);
if (config.standCrossbarSupports?.length > 0) {
md.push(`- **Crossbar Supports:** ${config.standCrossbarSupports.map(s => s.name).join(', ')}`);
}
md.push('');
}
// Remote
if (config.remote || config.remoteKnob) {
md.push(`## Remote`);
if (config.remote) md.push(`- **Type:** ${config.remote.name}`);
if (config.remoteKnob) md.push(`- **Knob:** ${config.remoteKnob.name}`);
md.push('');
}
// Filament Summary
if (filamentTotals.total > 0) {
md.push(`## Filament Summary`);
md.push(`- **Total:** ~${filamentTotals.total.toFixed(2)}g`);
if (filamentTotals.primary > 0) md.push(` - Primary: ~${filamentTotals.primary.toFixed(2)}g`);
if (filamentTotals.secondary > 0) md.push(` - Accent: ~${filamentTotals.secondary.toFixed(2)}g`);
md.push(`- **Estimated Print Time:** ${totalTime}`);
md.push('');
}
// Print Parts Summary
if (printedParts.length > 0) {
md.push(`## Printed Parts Summary`);
md.push(`- **Total Parts:** ${printedParts.length}`);
// Group by category
const partsByCategory = {};
printedParts.forEach(part => {
const category = part.category || 'Other';
if (!partsByCategory[category]) {
partsByCategory[category] = [];
}
partsByCategory[category].push(part);
});
Object.entries(partsByCategory).forEach(([category, parts]) => {
md.push(`### ${category} (${parts.length} parts)`);
parts.forEach(part => {
md.push(`- ${part.name}${part.filamentEstimate ? ` (~${part.filamentEstimate}g)` : ''}`);
});
});
md.push('');
}
// Hardware Summary
if (hardwareParts.length > 0) {
md.push(`## Hardware Summary`);
md.push(`- **Total Items:** ${hardwareParts.length}`);
md.push('');
}
// Cost Summary
if (total > 0) {
md.push(`## Estimated Cost`);
md.push(`- **Total:** $${total.toFixed(2)}`);
md.push('');
}
return md.join('\n');
};
// Generate Excel BOM with purchase links
export const generateExcelBOM = (hardwareParts, printedParts, config) => {
const rows = [];
// Header
rows.push(['Item', 'Name', 'Quantity', 'Price', 'Link', 'Category', 'Type']);
// Add motor
if (config.motor) {
const motorLinks = config.motor.links || [];
const firstLink = motorLinks.length > 0 ? motorLinks[0].link : '';
rows.push([
'Motor',
config.motor.name,
1,
config.motor.price,
firstLink,
'Motor',
'Hardware'
]);
}
// Add power supply
if (config.powerSupply) {
const psuLinks = config.powerSupply.links || [];
const firstLink = psuLinks.length > 0 ? psuLinks[0].link : '';
rows.push([
'Power Supply',
config.powerSupply.name,
1,
config.powerSupply.price,
firstLink,
'Power Supply',
'Hardware'
]);
}
// Add hardware parts
hardwareParts.forEach(hw => {
const links = hw.links || [];
const firstLink = links.length > 0 ? links[0].link : (hw.url || '');
rows.push([
hw.id || '',
hw.name || '',
hw.quantity || 1,
hw.price ? `$${parseFloat(hw.price).toFixed(2)}` : '',
firstLink,
hw.category || 'Hardware',
'Hardware'
]);
});
// Add printed parts (for reference, not purchase)
printedParts.forEach(part => {
rows.push([
part.id || '',
part.name || '',
1,
'N/A',
part.url || '',
part.category || 'Printed',
'Printed Part'
]);
});
// Create workbook and worksheet
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet(rows);
// Set column widths
ws['!cols'] = [
{ wch: 30 }, // Item
{ wch: 40 }, // Name
{ wch: 10 }, // Quantity
{ wch: 12 }, // Price
{ wch: 50 }, // Link
{ wch: 20 }, // Category
{ wch: 15 } // Type
];
XLSX.utils.book_append_sheet(wb, ws, 'BOM');
return wb;
};
// Generate Excel Print List with completion tracker
export const generateExcelPrintList = (printedParts, filamentTotals) => {
const rows = [];
// Header
rows.push(['Part Name', 'Category', 'Color', 'Quantity', 'Filament (g)', 'Print Time', 'Status', 'Completed']);
// Group parts by category and color
const partsByCategoryColor = {};
printedParts.forEach(part => {
const category = part.category || 'Other';
const color = part.colour === 'primary' ? 'Primary' : part.colour === 'secondary' ? 'Accent' : 'Other';
const key = `${category}_${color}`;
if (!partsByCategoryColor[key]) {
partsByCategoryColor[key] = [];
}
partsByCategoryColor[key].push(part);
});
// Sort by category, then color
const sortedKeys = Object.keys(partsByCategoryColor).sort();
sortedKeys.forEach(key => {
const parts = partsByCategoryColor[key];
const [category, color] = key.split('_');
parts.forEach(part => {
const filament = typeof part.filamentEstimate === 'number'
? part.filamentEstimate
: parseFloat(part.filamentEstimate?.replace('~', '').replace('g', '')) || 0;
rows.push([
part.name || part.id || '',
category,
color,
1,
filament > 0 ? filament.toFixed(2) : '',
part.timeEstimate || '',
'', // Status column (user fills in)
'' // Completed column (user checks)
]);
});
});
// Add summary row
rows.push([]);
rows.push(['TOTAL', '', '', printedParts.length, filamentTotals.total.toFixed(2), '', '', '']);
// Create workbook and worksheet
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet(rows);
// Set column widths
ws['!cols'] = [
{ wch: 40 }, // Part Name
{ wch: 20 }, // Category
{ wch: 12 }, // Color
{ wch: 10 }, // Quantity
{ wch: 15 }, // Filament
{ wch: 15 }, // Print Time
{ wch: 15 }, // Status
{ wch: 12 } // Completed
];
XLSX.utils.book_append_sheet(wb, ws, 'Print List');
// Create a summary sheet with progress calculation
// Note: Excel formulas need to reference cells properly
const summaryRows = [
['Print Progress Summary'],
[],
['Total Parts', printedParts.length],
['Completed Parts', { f: `COUNTIF('Print List'.H:H,"✓")` }],
['Progress %', { f: `IF(B3>0, (B4/B3)*100, 0)` }],
[],
['Filament Summary'],
['Total Filament (g)', filamentTotals.total.toFixed(2)],
['Primary Color (g)', filamentTotals.primary.toFixed(2)],
['Accent Color (g)', (filamentTotals.secondary || 0).toFixed(2)]
];
const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows);
summaryWs['!cols'] = [
{ wch: 25 },
{ wch: 15 }
];
XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary');
return wb;
};

View File

@@ -0,0 +1,26 @@
// Helper function to format price (handles both number and string prices)
export function formatPrice(price) {
if (typeof price === 'number') {
return `$${price.toFixed(2)}`;
}
if (typeof price === 'string') {
// If it's already formatted as a string (e.g., "$125-$250"), return as-is
return price.startsWith('$') ? price : `$${price}`;
}
return '$0.00';
}
// Helper function to get numeric price for calculations (returns 0 for string prices)
export function getNumericPrice(price) {
if (typeof price === 'number') {
return price;
}
if (typeof price === 'string') {
// Try to extract a number from string prices like "$125-$250"
const match = price.match(/\d+/);
if (match) {
return parseFloat(match[0]);
}
}
return 0;
}

View File

@@ -0,0 +1,95 @@
// Share service for creating 7-day shareable links
// Uses localStorage to store shared configs with expiration
const SHARE_PREFIX = 'ossm_share_';
const SHARE_EXPIRY_DAYS = 7;
export const createShareLink = (config) => {
// Generate a unique ID for this share
const shareId = `share_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
// Calculate expiration date (7 days from now)
const expiresAt = Date.now() + (SHARE_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
// Store the config in localStorage with expiration
const shareData = {
config,
expiresAt,
createdAt: Date.now()
};
try {
localStorage.setItem(`${SHARE_PREFIX}${shareId}`, JSON.stringify(shareData));
// Clean up expired shares
cleanupExpiredShares();
// Return the shareable URL
const currentUrl = window.location.origin + window.location.pathname;
return `${currentUrl}?share=${shareId}`;
} catch (error) {
console.error('Error creating share link:', error);
// If localStorage is full, try using sessionStorage as fallback
try {
sessionStorage.setItem(`${SHARE_PREFIX}${shareId}`, JSON.stringify(shareData));
const currentUrl = window.location.origin + window.location.pathname;
return `${currentUrl}?share=${shareId}&session=true`;
} catch (fallbackError) {
console.error('Error with fallback storage:', fallbackError);
throw new Error('Unable to create share link. Please try again.');
}
}
};
export const getSharedConfig = (shareId, isSession = false) => {
const storage = isSession ? sessionStorage : localStorage;
const key = `${SHARE_PREFIX}${shareId}`;
const shareDataStr = storage.getItem(key);
if (!shareDataStr) {
return null;
}
try {
const shareData = JSON.parse(shareDataStr);
// Check if expired
if (shareData.expiresAt && Date.now() > shareData.expiresAt) {
storage.removeItem(key);
return null;
}
return shareData.config;
} catch (error) {
console.error('Error reading share data:', error);
return null;
}
};
export const cleanupExpiredShares = () => {
try {
const keys = Object.keys(localStorage);
const now = Date.now();
keys.forEach(key => {
if (key.startsWith(SHARE_PREFIX)) {
try {
const shareData = JSON.parse(localStorage.getItem(key));
if (shareData.expiresAt && now > shareData.expiresAt) {
localStorage.removeItem(key);
}
} catch (e) {
// Invalid data, remove it
localStorage.removeItem(key);
}
}
});
} catch (error) {
console.error('Error cleaning up expired shares:', error);
}
};
export const deleteShare = (shareId, isSession = false) => {
const storage = isSession ? sessionStorage : localStorage;
storage.removeItem(`${SHARE_PREFIX}${shareId}`);
};