Initial commit: OSSM Configurator with share and export functionality

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

21
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/prop-types': 'off',
},
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

BIN
BOM.xlsx Normal file

Binary file not shown.

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# OSSM Configurator
A web-based configuration tool for the Open Source Sex Machine (OSSM) project. This application provides an intuitive wizard interface that guides users through selecting and customizing components for their OSSM build, generating a complete Bill of Materials (BOM) and configuration summary.
## Project Structure
```
OSSM-Configurator/
├── website/ # Main web application
│ ├── src/ # React source code
│ ├── public/ # Static assets (images, etc.)
│ ├── dist/ # Build output (generated)
│ ├── node_modules/ # Dependencies (generated)
│ └── ... # Configuration files
├── BOM.xlsx # Bill of Materials spreadsheet
├── Screen Shots/ # Application screenshots
└── README.md # This file
```
## Website Overview
The OSSM Configurator is a React-based single-page application built with Vite. It provides a step-by-step wizard interface that allows users to:
1. **Select Motor** - Choose from available motor options (42AIM30, 57AIM30, iHSV57)
2. **Choose Power Supply** - Select appropriate power supply (24V PSU, 24V USB-C PD)
3. **Customize Colors** - Pick primary and accent colors for 3D printed parts
4. **Configure Options** - Select mounting options, stands, toy mounts, actuators, and other components
5. **Review Summary** - View complete BOM with pricing, filament estimates, and export options
### Key Features
- **Interactive Wizard Interface**: Step-by-step configuration process with progress tracking
- **Component Compatibility**: Ensures selected components are compatible with each other
- **Real-time Pricing**: Calculates total cost including hardware and printed parts
- **Filament Estimates**: Provides 3D printing filament requirements for each component
- **BOM Export**: Generate and download a complete Bill of Materials
- **Visual Component Selection**: Image-based component selection for better user experience
### Technology Stack
- **React 18** - UI framework
- **Vite** - Build tool and dev server
- **Tailwind CSS** - Styling
- **JSZip** - For generating downloadable BOM packages
## Getting Started
### Prerequisites
- Node.js (v16 or higher recommended)
- npm or yarn
### Installation
1. Navigate to the website directory:
```bash
cd website
```
2. Install dependencies:
```bash
npm install
```
### Development
Run the development server:
```bash
npm run dev
```
The application will be available at `http://localhost:5173` (or the port shown in the terminal).
### Building for Production
Create an optimized production build:
```bash
npm run build
```
The built files will be in the `website/dist/` directory.
### Preview Production Build
Preview the production build locally:
```bash
npm run preview
```
## Configuration Data
The application uses JSON data files located in `website/src/data/`:
- `motors.json` - Available motor options
- `powerSupplies.json` - Power supply options
- `colors.json` - Available color options
- `options.json` - Main configuration options
- `components/` - Detailed component data:
- `actuator.json` - Actuator components
- `mounting.json` - Mounting options
- `remote.json` - Remote control components
- `stand.json` - Stand components
- `toyMounts.json` - Toy mount options
## Project Purpose
The OSSM Configurator serves as a comprehensive tool for users building their own Open Source Sex Machine. It simplifies the configuration process by:
- **Guiding Selection**: Step-by-step wizard prevents missing critical components
- **Ensuring Compatibility**: Validates component combinations
- **Providing Transparency**: Shows costs, filament requirements, and time estimates
- **Generating Documentation**: Creates exportable BOM for ordering parts and printing
This tool is essential for both beginners and experienced builders who want to ensure they have all necessary components and understand the full scope of their build before starting.
## Contributing
When adding new components or options:
1. Update the appropriate JSON data files in `website/src/data/`
2. Add corresponding images to `website/public/images/`
3. Test the configuration flow to ensure compatibility
4. Update component pricing and filament estimates as needed
## License
This project is part of the Open Source Sex Machine (OSSM) project. Please refer to the OSSM project license for usage terms.

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
Screen Shots/Summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

13
website/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OSSM Configurator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5754
website/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
website/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "ossm-configurator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.9",
"vite": "^5.4.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,10 @@
# Images Directory
Place product images in the appropriate subdirectories:
- `motors/` - Motor images (e.g., 57AIM30.jpg, 42AIM30.jpg, iHSV57.jpg)
- `power-supplies/` - Power supply images (e.g., 12v-5a.jpg, 12v-8a.jpg)
Image paths are referenced in `src/data/parts.json`. The image paths should match the filenames you place here.
Supported image formats: JPG, PNG, WebP, etc.

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

7
website/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

BIN
~$BOM.xlsx Normal file

Binary file not shown.