Initial commit: OSSM Configurator with share and export functionality
This commit is contained in:
161
website/src/App.jsx
Normal file
161
website/src/App.jsx
Normal 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;
|
||||
1541
website/src/components/BOMSummary.jsx
Normal file
1541
website/src/components/BOMSummary.jsx
Normal file
File diff suppressed because it is too large
Load Diff
125
website/src/components/MainPage.jsx
Normal file
125
website/src/components/MainPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
website/src/components/Wizard.jsx
Normal file
302
website/src/components/Wizard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
website/src/components/steps/ColorsStep.jsx
Normal file
108
website/src/components/steps/ColorsStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
website/src/components/steps/MotorStep.jsx
Normal file
159
website/src/components/steps/MotorStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
446
website/src/components/steps/OptionsStep.jsx
Normal file
446
website/src/components/steps/OptionsStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
website/src/components/steps/PowerSupplyStep.jsx
Normal file
136
website/src/components/steps/PowerSupplyStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
website/src/components/steps/RemoteStep.jsx
Normal file
322
website/src/components/steps/RemoteStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
website/src/components/steps/ToyMountStep.jsx
Normal file
201
website/src/components/steps/ToyMountStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
website/src/data/colors.json
Normal file
76
website/src/data/colors.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
website/src/data/compatibility.json
Normal file
29
website/src/data/compatibility.json
Normal 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
|
||||
}
|
||||
}
|
||||
188
website/src/data/components/actuator.json
Normal file
188
website/src/data/components/actuator.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
138
website/src/data/components/mounting.json
Normal file
138
website/src/data/components/mounting.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
108
website/src/data/components/remote.json
Normal file
108
website/src/data/components/remote.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
212
website/src/data/components/stand.json
Normal file
212
website/src/data/components/stand.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
115
website/src/data/components/toyMounts.json
Normal file
115
website/src/data/components/toyMounts.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
148
website/src/data/hardware.json
Normal file
148
website/src/data/hardware.json
Normal 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
250
website/src/data/index.js
Normal 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,
|
||||
};
|
||||
38
website/src/data/motors.json
Normal file
38
website/src/data/motors.json
Normal 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
|
||||
}
|
||||
]
|
||||
123
website/src/data/options.json
Normal file
123
website/src/data/options.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
website/src/data/powerSupplies.json
Normal file
60
website/src/data/powerSupplies.json
Normal 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
12
website/src/index.css
Normal 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
10
website/src/main.jsx
Normal 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>,
|
||||
)
|
||||
295
website/src/utils/exportUtils.js
Normal file
295
website/src/utils/exportUtils.js
Normal 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;
|
||||
};
|
||||
26
website/src/utils/priceFormat.js
Normal file
26
website/src/utils/priceFormat.js
Normal 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;
|
||||
}
|
||||
95
website/src/utils/shareService.js
Normal file
95
website/src/utils/shareService.js
Normal 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}`);
|
||||
};
|
||||
Reference in New Issue
Block a user