Initial commit: OSSM Configurator with share and export functionality
21
.eslintrc.cjs
Normal 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
@@ -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?
|
||||
3
.vite/deps_temp_93d425d7/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
130
README.md
Normal 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.
|
||||
BIN
Screen Shots/Color Selector.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
Screen Shots/Motor-Selected.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
Screen Shots/Motor-unselected.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
Screen Shots/Options List.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
Screen Shots/PSU selector.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
Screen Shots/Summary.png
Normal file
|
After Width: | Height: | Size: 596 KiB |
BIN
custom-covers/blank/OSSM - Actuator - Body - Cover.3mf
Normal file
13
website/index.html
Normal 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
31
website/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
website/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
10
website/public/images/README.md
Normal 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.
|
||||
BIN
website/public/images/motors/42AIM30.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
website/public/images/motors/57AIM30.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
website/public/images/motors/iHSV57.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
website/public/images/options/PitClamp Mini Base.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
website/public/images/options/middle-pivot.png
Normal file
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 164 KiB |
BIN
website/public/images/options/pivot-plate.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
website/public/images/power-supplies/24v-PSU.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
website/public/images/power-supplies/24v-usbc-pd.png
Normal file
|
After Width: | Height: | Size: 603 KiB |
BIN
website/public/images/remote/radr-remote.png
Normal file
|
After Width: | Height: | Size: 900 KiB |
BIN
website/public/images/remote/standard-remote.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
161
website/src/App.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import MainPage from './components/MainPage';
|
||||
import Wizard from './components/Wizard';
|
||||
import partsData from './data/index.js';
|
||||
import { getSharedConfig } from './utils/shareService';
|
||||
|
||||
function App() {
|
||||
const [buildType, setBuildType] = useState(null);
|
||||
const [config, setConfig] = useState({
|
||||
motor: null,
|
||||
powerSupply: null,
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount: null,
|
||||
cover: null,
|
||||
standHinge: null,
|
||||
standFeet: null,
|
||||
standCrossbarSupports: [],
|
||||
toyMountOptions: [],
|
||||
toyMounts: [],
|
||||
actuatorMount: null,
|
||||
standParts: [],
|
||||
pcbMount: null,
|
||||
});
|
||||
|
||||
// Check for share link on load
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const shareId = urlParams.get('share');
|
||||
const isSession = urlParams.get('session') === 'true';
|
||||
|
||||
if (shareId) {
|
||||
const sharedConfig = getSharedConfig(shareId, isSession);
|
||||
if (sharedConfig) {
|
||||
setConfig(sharedConfig);
|
||||
setBuildType('self-source'); // Default build type for shared configs
|
||||
// Clean up the URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
} else {
|
||||
alert('This share link has expired or is invalid. Share links are valid for 7 days.');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectBuildType = (type) => {
|
||||
if (type === 'rad-kit') {
|
||||
// Pre-select RAD kit parts
|
||||
const radKitConfig = getRADKitConfig();
|
||||
setConfig(radKitConfig);
|
||||
setBuildType('rad-kit');
|
||||
} else {
|
||||
setBuildType(type);
|
||||
}
|
||||
};
|
||||
|
||||
const getRADKitConfig = () => {
|
||||
// Standard RAD kit configuration
|
||||
// Assuming the kit includes:
|
||||
// - 57AIM30 motor (recommended)
|
||||
// - 24V 5A PSU
|
||||
// - Standard mount (middle-pivot or pitclamp - using middle-pivot as default)
|
||||
// - Standard colors (black/black)
|
||||
// - Basic stand components
|
||||
// - Default toy mount options (flange mount base)
|
||||
|
||||
const motor = partsData.motors.find(m => m.id === '57AIM30') || partsData.motors[0];
|
||||
const powerSupply = partsData.powerSupplies.find(ps => ps.id === 'psu-24v-5a') || partsData.powerSupplies[0];
|
||||
|
||||
// Get mount from options data to ensure proper structure
|
||||
const mountOptions = partsData.options?.actuator?.sections?.mounts?.options || [];
|
||||
const mount = mountOptions.find(m => m.id === 'middle-pivot') || mountOptions[0] || null;
|
||||
|
||||
// Get stand hinge (default: pivot-plate)
|
||||
const hingeOptions = partsData.options?.stand?.sections?.hinges?.options || [];
|
||||
const standHinge = hingeOptions.find(h => h.id === 'pivot-plate') || hingeOptions[0] || null;
|
||||
|
||||
// Get stand feet (default: standard-feet)
|
||||
const feetOptions = partsData.options?.stand?.sections?.feet?.options || [];
|
||||
const standFeet = feetOptions.find(f => f.id === 'standard-feet') || feetOptions[0] || null;
|
||||
|
||||
// Get stand crossbar supports (default: standard-90-degree-support)
|
||||
const crossbarSupportOptions = partsData.options?.stand?.sections?.crossbarSupports?.options || [];
|
||||
const standCrossbarSupports = [];
|
||||
const standardSupport = crossbarSupportOptions.find(s => s.id === 'standard-90-degree-support') || crossbarSupportOptions[0];
|
||||
if (standardSupport) {
|
||||
standCrossbarSupports.push(standardSupport);
|
||||
}
|
||||
|
||||
// Get default toy mount options (flange mount base - first option)
|
||||
const toyMountOptions = [];
|
||||
const flangeMountOptions = partsData.options?.toyMounts?.sections?.flangeMount?.options || [];
|
||||
if (flangeMountOptions.length > 0) {
|
||||
// Select the first option (flange-base-24mm-threaded)
|
||||
toyMountOptions.push(flangeMountOptions[0]);
|
||||
}
|
||||
|
||||
// Get cover (default: standard-cover)
|
||||
const coverOptions = partsData.options?.actuator?.sections?.cover?.options || [];
|
||||
const cover = coverOptions.find(c => c.id === 'standard-cover') || coverOptions[0] || null;
|
||||
|
||||
// Get PCB mount (default: 3030-mount)
|
||||
const pcbMountOptions = partsData.options?.actuator?.sections?.pcbMount?.options || [];
|
||||
const pcbMount = pcbMountOptions.find(p => p.id === '3030-mount') || pcbMountOptions[0] || null;
|
||||
|
||||
return {
|
||||
motor,
|
||||
powerSupply,
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount,
|
||||
cover,
|
||||
standHinge,
|
||||
standFeet,
|
||||
standCrossbarSupports,
|
||||
toyMountOptions,
|
||||
toyMounts: [],
|
||||
actuatorMount: null,
|
||||
standParts: [],
|
||||
pcbMount,
|
||||
};
|
||||
};
|
||||
|
||||
const updateConfig = (updates) => {
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleBackToMain = () => {
|
||||
setBuildType(null);
|
||||
setConfig({
|
||||
motor: null,
|
||||
powerSupply: null,
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount: null,
|
||||
cover: null,
|
||||
standHinge: null,
|
||||
standFeet: null,
|
||||
standCrossbarSupports: [],
|
||||
toyMountOptions: [],
|
||||
toyMounts: [],
|
||||
actuatorMount: null,
|
||||
standParts: [],
|
||||
pcbMount: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!buildType) {
|
||||
return <MainPage onSelectBuildType={handleSelectBuildType} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
buildType={buildType}
|
||||
initialConfig={config}
|
||||
updateConfig={updateConfig}
|
||||
onBackToMain={handleBackToMain}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1541
website/src/components/BOMSummary.jsx
Normal file
125
website/src/components/MainPage.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import partsData from '../data/index.js';
|
||||
|
||||
export default function MainPage({ onSelectBuildType }) {
|
||||
const handleSelect = (buildType) => {
|
||||
onSelectBuildType(buildType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
OSSM Configurator
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Configure your Open Source Sex Machine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Build Type Selection */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
Select Your Build Type
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* New Build - RAD Kit */}
|
||||
<button
|
||||
onClick={() => handleSelect('rad-kit')}
|
||||
className="flex flex-col items-center p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-4 group-hover:bg-blue-200 transition-colors">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
New Build
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-blue-600 mb-3">
|
||||
Kit from RAD
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
Pre-configured kit with all required parts. Jump straight to the summary.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* New Build - Self Source */}
|
||||
<button
|
||||
onClick={() => handleSelect('self-source')}
|
||||
className="flex flex-col items-center p-6 border-2 border-gray-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4 group-hover:bg-green-200 transition-colors">
|
||||
<svg
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
New Build
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-green-600 mb-3">
|
||||
Self Source
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
Go through the full wizard to select and customize all components.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Upgrade */}
|
||||
<button
|
||||
onClick={() => handleSelect('upgrade')}
|
||||
className="flex flex-col items-center p-6 border-2 border-gray-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mb-4 group-hover:bg-purple-200 transition-colors">
|
||||
<svg
|
||||
className="w-8 h-8 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Upgrade / Mod
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-purple-600 mb-3">
|
||||
Add Modifications
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
Browse and select upgrade components and modifications for your existing build.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
website/src/components/Wizard.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import MotorStep from './steps/MotorStep';
|
||||
import PowerSupplyStep from './steps/PowerSupplyStep';
|
||||
import ColorsStep from './steps/ColorsStep';
|
||||
import OptionsStep from './steps/OptionsStep';
|
||||
import RemoteStep from './steps/RemoteStep';
|
||||
import ToyMountStep from './steps/ToyMountStep';
|
||||
import BOMSummary from './BOMSummary';
|
||||
|
||||
const steps = [
|
||||
{ id: 'motor', name: 'Motor', component: MotorStep },
|
||||
{ id: 'powersupply', name: 'Power Supply', component: PowerSupplyStep },
|
||||
{ id: 'colors', name: 'Colors', component: ColorsStep },
|
||||
{ id: 'options', name: 'Options', component: OptionsStep },
|
||||
{ id: 'remote', name: 'Remote', component: RemoteStep },
|
||||
{ id: 'toymounts', name: 'Toy Mounts', component: ToyMountStep },
|
||||
{ id: 'summary', name: 'Summary', component: BOMSummary },
|
||||
];
|
||||
|
||||
export default function Wizard({ buildType = 'self-source', initialConfig, updateConfig: updateConfigProp, onBackToMain }) {
|
||||
// For RAD Kit, start at Remote step (index 4)
|
||||
const getInitialStep = () => {
|
||||
if (buildType === 'rad-kit') {
|
||||
// Remote step is at index 4 (Motor=0, PowerSupply=1, Colors=2, Options=3, Remote=4, ToyMounts=5, Summary=6)
|
||||
return 4;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(getInitialStep());
|
||||
const [config, setConfig] = useState(initialConfig || {
|
||||
motor: '57AIM30',
|
||||
powerSupply: '24V PSU',
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount: 'Middle Pivot',
|
||||
cover: 'Simple',
|
||||
standHinge: 'Pivot Plate',
|
||||
standFeet: '3030 Extrusion',
|
||||
standCrossbarSupports: 'standard',
|
||||
pcbMount: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialConfig) {
|
||||
setConfig(initialConfig);
|
||||
}
|
||||
}, [initialConfig]);
|
||||
|
||||
const updateConfig = (updates) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
setConfig(newConfig);
|
||||
if (updateConfigProp) {
|
||||
updateConfigProp(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter steps for upgrade mode - skip motor and power supply, start with options
|
||||
const getFilteredSteps = () => {
|
||||
if (buildType === 'upgrade') {
|
||||
// For upgrade mode, only show options and summary
|
||||
return [
|
||||
{ id: 'options', name: 'Upgrade Options', component: OptionsStep },
|
||||
{ id: 'summary', name: 'Summary', component: BOMSummary },
|
||||
];
|
||||
}
|
||||
return steps;
|
||||
};
|
||||
|
||||
const filteredSteps = getFilteredSteps();
|
||||
|
||||
const nextStep = () => {
|
||||
// In upgrade mode, no validation needed
|
||||
if (buildType === 'upgrade') {
|
||||
if (currentStep < filteredSteps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required selections before moving to next step
|
||||
if (currentStep === 0 && !config.motor) {
|
||||
// Motor step - require motor selection
|
||||
return;
|
||||
}
|
||||
if (currentStep === 1 && !config.powerSupply) {
|
||||
// Power Supply step - require power supply selection
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep < filteredSteps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const canNavigateToStep = (stepIndex) => {
|
||||
// Can always go back or stay on current step
|
||||
if (stepIndex <= currentStep) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In upgrade mode, no validation needed
|
||||
if (buildType === 'upgrade') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In RAD Kit mode, all steps are pre-selected, so navigation is always allowed
|
||||
if (buildType === 'rad-kit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if required steps are completed before jumping ahead
|
||||
if (stepIndex > 0 && !config.motor) {
|
||||
return false; // Can't skip motor step
|
||||
}
|
||||
if (stepIndex > 1 && !config.powerSupply) {
|
||||
return false; // Can't skip power supply step
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const goToStep = (stepIndex) => {
|
||||
if (stepIndex >= 0 && stepIndex < filteredSteps.length && canNavigateToStep(stepIndex)) {
|
||||
setCurrentStep(stepIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceedToNextStep = () => {
|
||||
// In upgrade mode, no validation needed
|
||||
if (buildType === 'upgrade') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep === 0 && !config.motor) {
|
||||
return false; // Motor step - require motor selection
|
||||
}
|
||||
if (currentStep === 1 && !config.powerSupply) {
|
||||
return false; // Power Supply step - require power supply selection
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const CurrentStepComponent = filteredSteps[currentStep].component;
|
||||
|
||||
// Adjust current step if we're in upgrade mode
|
||||
useEffect(() => {
|
||||
if (buildType === 'upgrade' && currentStep >= 2) {
|
||||
// Skip to options step (index 0 in filtered steps)
|
||||
setCurrentStep(0);
|
||||
}
|
||||
}, [buildType, currentStep]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
{onBackToMain && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={onBackToMain}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Back to Main Menu
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
OSSM Configurator
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{buildType === 'upgrade'
|
||||
? 'Select upgrade components and modifications'
|
||||
: 'Configure your Open Source Sex Machine'}
|
||||
</p>
|
||||
{buildType === 'upgrade' && (
|
||||
<div className="mt-4 bg-purple-50 border border-purple-200 rounded-lg p-3 inline-block">
|
||||
<p className="text-purple-800 text-sm font-medium">
|
||||
Upgrade Mode: Only modification and upgrade components are shown
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between relative">
|
||||
{filteredSteps.map((step, index) => (
|
||||
<div key={step.id} className="flex flex-col items-center flex-1 relative">
|
||||
{/* Circle */}
|
||||
<button
|
||||
onClick={() => goToStep(index)}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold flex-shrink-0 z-10 ${
|
||||
index === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: index < currentStep
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
} ${
|
||||
index <= currentStep
|
||||
? 'cursor-pointer hover:opacity-80'
|
||||
: 'cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!canNavigateToStep(index)}
|
||||
>
|
||||
{index < currentStep ? '✓' : index + 1}
|
||||
</button>
|
||||
{/* Connecting line to the right */}
|
||||
{index < filteredSteps.length - 1 && (
|
||||
<div
|
||||
className={`absolute top-5 left-1/2 h-1 ${
|
||||
index < currentStep ? 'bg-green-500' : 'bg-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
width: 'calc(100% - 40px)',
|
||||
marginLeft: '20px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Text label */}
|
||||
<button
|
||||
onClick={() => goToStep(index)}
|
||||
className={`mt-2 text-sm font-medium text-center ${
|
||||
index <= currentStep
|
||||
? 'text-blue-600 cursor-pointer hover:text-blue-800'
|
||||
: 'text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!canNavigateToStep(index)}
|
||||
>
|
||||
{step.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 md:p-8 mb-6">
|
||||
<CurrentStepComponent
|
||||
config={config}
|
||||
updateConfig={updateConfig}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
buildType={buildType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
{currentStep < filteredSteps.length - 1 && (
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium ${
|
||||
currentStep === 0
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-600 text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={nextStep}
|
||||
disabled={!canProceedToNextStep()}
|
||||
className={`px-6 py-2 rounded-lg font-medium ${
|
||||
canProceedToNextStep()
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
website/src/components/steps/ColorsStep.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import partsData from '../../data/index.js';
|
||||
|
||||
export default function ColorsStep({ config, updateConfig }) {
|
||||
const handlePrimaryColorSelect = (color) => {
|
||||
updateConfig({ primaryColor: color.id });
|
||||
};
|
||||
|
||||
const handleAccentColorSelect = (color) => {
|
||||
updateConfig({ accentColor: color.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Select Colors</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Choose primary and accent colors for your OSSM build.
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Primary Color */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Primary Color</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
|
||||
{partsData.colors.primary.map((color) => (
|
||||
<button
|
||||
key={color.id}
|
||||
onClick={() => handlePrimaryColorSelect(color)}
|
||||
className={`flex flex-col items-center p-4 border-2 rounded-lg transition-all ${
|
||||
config.primaryColor === color.id
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 rounded-full mb-2 border-2 border-gray-300"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{color.name}
|
||||
</span>
|
||||
{config.primaryColor === color.id && (
|
||||
<div className="mt-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Color */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Accent Color</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
|
||||
{partsData.colors.accent.map((color) => (
|
||||
<button
|
||||
key={color.id}
|
||||
onClick={() => handleAccentColorSelect(color)}
|
||||
className={`flex flex-col items-center p-4 border-2 rounded-lg transition-all ${
|
||||
config.accentColor === color.id
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 rounded-full mb-2 border-2 border-gray-300"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{color.name}
|
||||
</span>
|
||||
{config.accentColor === color.id && (
|
||||
<div className="mt-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
website/src/components/steps/MotorStep.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
|
||||
export default function MotorStep({ config, updateConfig }) {
|
||||
const selectedMotorId = config.motor?.id;
|
||||
|
||||
const handleSelect = (motor) => {
|
||||
updateConfig({ motor });
|
||||
};
|
||||
|
||||
const recommendedMotors = partsData.motors.filter(m => m.recommended);
|
||||
const otherMotors = partsData.motors.filter(m => !m.recommended);
|
||||
const hasSingleRecommended = recommendedMotors.length === 1;
|
||||
|
||||
const renderMotorCard = (motor, isRecommended = false, isSlightlyLarger = false) => (
|
||||
<button
|
||||
key={motor.id}
|
||||
onClick={() => handleSelect(motor)}
|
||||
className={`${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${
|
||||
selectedMotorId === motor.id
|
||||
? 'border-blue-600 bg-blue-50 shadow-lg'
|
||||
: motor.recommended
|
||||
? 'border-green-500 bg-green-50 hover:border-green-600 hover:bg-green-100'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{motor.recommended && (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-3 py-1 text-xs font-semibold text-green-800 bg-green-200 rounded-full">
|
||||
⭐ Recommended
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{motor.image && (
|
||||
<div className={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}>
|
||||
<img
|
||||
src={motor.image}
|
||||
alt={motor.name}
|
||||
className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100`}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className={`${isSlightlyLarger ? 'text-lg' : 'text-base'} font-semibold text-gray-900`}>
|
||||
{motor.name}
|
||||
</h3>
|
||||
{selectedMotorId === motor.id && (
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={`${isSlightlyLarger ? 'text-sm' : 'text-sm'} text-gray-600 mb-3`}>{motor.description}</p>
|
||||
<div className={`flex ${isSlightlyLarger ? 'gap-4' : 'gap-3'} text-sm`}>
|
||||
<div>
|
||||
<span className="text-gray-500">Speed:</span>{' '}
|
||||
<span className="font-medium">{motor.speed}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Wattage:</span>{' '}
|
||||
<span className="font-medium">{motor.wattage}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Gear Count:</span>{' '}
|
||||
<span className="font-medium">{motor.gear_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${isSlightlyLarger ? 'mt-3' : 'mt-2'} flex items-center justify-between`}>
|
||||
<div className={`${isSlightlyLarger ? 'text-lg' : 'text-lg'} font-bold text-blue-600`}>
|
||||
{formatPrice(motor.price)}
|
||||
</div>
|
||||
</div>
|
||||
{motor.links && motor.links.length > 0 && (
|
||||
<div className={`${isSlightlyLarger ? 'mt-3' : 'mt-2'} pt-3 border-t border-gray-200`}>
|
||||
<p className="text-xs text-gray-500 mb-2">Buy from:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{motor.links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 hover:text-blue-800 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 mr-1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{link.store}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Select Motor</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Choose the stepper motor for your OSSM build.
|
||||
</p>
|
||||
|
||||
{/* Recommended Motor(s) */}
|
||||
{recommendedMotors.length > 0 && (
|
||||
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center' : ''}`}>
|
||||
{hasSingleRecommended ? (
|
||||
<div className="w-full max-w-md">
|
||||
{renderMotorCard(recommendedMotors[0], true, true)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-700">Recommended Options</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{recommendedMotors.map((motor) => renderMotorCard(motor, true, false))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Motors - Smaller Grid */}
|
||||
{otherMotors.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-700">Other Options</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{otherMotors.map((motor) => renderMotorCard(motor, false, false))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
446
website/src/components/steps/OptionsStep.jsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
|
||||
export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
const [expandedMainSections, setExpandedMainSections] = useState({});
|
||||
const [expandedSubSections, setExpandedSubSections] = useState({});
|
||||
|
||||
const handleMountSelect = (option) => {
|
||||
updateConfig({ mount: option });
|
||||
setExpandedSubSections((prev) => ({ ...prev, 'actuator.mounts': false }));
|
||||
};
|
||||
|
||||
const handleCoverSelect = (option) => {
|
||||
updateConfig({ cover: option });
|
||||
setExpandedSubSections((prev) => ({ ...prev, 'actuator.cover': false }));
|
||||
};
|
||||
|
||||
const handlePcbMountSelect = (option) => {
|
||||
updateConfig({ pcbMount: option });
|
||||
setExpandedSubSections((prev) => ({ ...prev, 'actuator.pcbMount': false }));
|
||||
};
|
||||
|
||||
const handleStandHingeSelect = (option) => {
|
||||
updateConfig({ standHinge: option });
|
||||
setExpandedSubSections((prev) => ({ ...prev, 'stand.hinges': false }));
|
||||
};
|
||||
|
||||
const handleStandFeetSelect = (option) => {
|
||||
updateConfig({ standFeet: option });
|
||||
setExpandedSubSections((prev) => ({ ...prev, 'stand.feet': false }));
|
||||
};
|
||||
|
||||
const handleStandCrossbarSupportToggle = (option) => {
|
||||
const currentSupports = config.standCrossbarSupports || [];
|
||||
const isSelected = currentSupports.some((opt) => opt.id === option.id);
|
||||
|
||||
if (isSelected) {
|
||||
updateConfig({
|
||||
standCrossbarSupports: currentSupports.filter((opt) => opt.id !== option.id),
|
||||
});
|
||||
} else {
|
||||
updateConfig({
|
||||
standCrossbarSupports: [...currentSupports, option],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMainSection = (mainSectionId) => {
|
||||
setExpandedMainSections((prev) => ({
|
||||
...prev,
|
||||
[mainSectionId]: !prev[mainSectionId],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleSubSection = (subSectionKey) => {
|
||||
setExpandedSubSections((prev) => ({
|
||||
...prev,
|
||||
[subSectionKey]: !prev[subSectionKey],
|
||||
}));
|
||||
};
|
||||
|
||||
const getSelectedOptionsForSubSection = (mainSectionId, subSectionId, subSection = null) => {
|
||||
const key = `${mainSectionId}.${subSectionId}`;
|
||||
|
||||
switch (key) {
|
||||
case 'actuator.mounts':
|
||||
return config.mount ? [config.mount] : [];
|
||||
case 'actuator.cover':
|
||||
return config.cover ? [config.cover] : [];
|
||||
case 'actuator.pcbMount':
|
||||
return config.pcbMount ? [config.pcbMount] : [];
|
||||
case 'stand.hinges':
|
||||
return config.standHinge ? [config.standHinge] : [];
|
||||
case 'stand.feet':
|
||||
return config.standFeet ? [config.standFeet] : [];
|
||||
case 'stand.crossbarSupports':
|
||||
return config.standCrossbarSupports || [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const isOptionSelected = (option, mainSectionId, subSectionId, subSection = null) => {
|
||||
const selected = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
|
||||
return selected.some((opt) => opt.id === option.id);
|
||||
};
|
||||
|
||||
const isMainSectionComplete = (mainSectionId, mainSection) => {
|
||||
const subSections = Object.entries(mainSection.sections || {});
|
||||
|
||||
// Check if all sub-sections with options are complete
|
||||
for (const [subSectionId, subSection] of subSections) {
|
||||
// Skip if no options available
|
||||
if (!subSection.options || subSection.options.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const selectedOptions = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
|
||||
|
||||
// All sub-sections (both single-select and multi-select) require at least one selection
|
||||
// Multi-select means you can select multiple items, but you still need at least one
|
||||
if (selectedOptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Auto-collapse main sections when they become complete
|
||||
useEffect(() => {
|
||||
const mainSections = partsData.options ? Object.entries(partsData.options) : [];
|
||||
|
||||
mainSections.forEach(([mainSectionId, mainSection]) => {
|
||||
// Skip toyMounts and remoteControl sections (now in their own steps)
|
||||
if (mainSectionId === 'toyMounts' || mainSectionId === 'remoteControl') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMainSectionComplete(mainSectionId, mainSection)) {
|
||||
setExpandedMainSections((prev) => {
|
||||
// Only auto-collapse if the section is currently expanded (undefined or true)
|
||||
// Don't override if user has explicitly collapsed it (false)
|
||||
const currentlyExpanded = prev[mainSectionId] !== false;
|
||||
if (currentlyExpanded) {
|
||||
return { ...prev, [mainSectionId]: false };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.mount, config.cover, config.pcbMount, config.standHinge, config.standFeet, config.standCrossbarSupports]);
|
||||
|
||||
const handleOptionClick = (option, mainSectionId, subSectionId) => {
|
||||
const key = `${mainSectionId}.${subSectionId}`;
|
||||
|
||||
switch (key) {
|
||||
case 'actuator.mounts':
|
||||
handleMountSelect(option);
|
||||
break;
|
||||
case 'actuator.cover':
|
||||
handleCoverSelect(option);
|
||||
break;
|
||||
case 'actuator.pcbMount':
|
||||
handlePcbMountSelect(option);
|
||||
break;
|
||||
case 'stand.hinges':
|
||||
handleStandHingeSelect(option);
|
||||
break;
|
||||
case 'stand.feet':
|
||||
handleStandFeetSelect(option);
|
||||
break;
|
||||
case 'stand.crossbarSupports':
|
||||
handleStandCrossbarSupportToggle(option);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderOptionCard = (option, mainSectionId, subSectionId, subSection = null, isMultiSelect = false) => {
|
||||
const isSelected = isOptionSelected(option, mainSectionId, subSectionId, subSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleOptionClick(option, mainSectionId, subSectionId)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{option.image && (
|
||||
<div className="mb-3 flex justify-center">
|
||||
<img
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className="h-48 w-48 object-contain rounded-lg bg-gray-100"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">
|
||||
{option.name}
|
||||
</h4>
|
||||
{option.description && (
|
||||
<p className="text-sm text-gray-600">{option.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||
{isMultiSelect ? (
|
||||
<span className="text-white text-sm font-bold">✓</span>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm mt-3">
|
||||
{option.filamentEstimate && (
|
||||
<div>
|
||||
<span className="text-gray-500">Filament:</span>{' '}
|
||||
<span className="font-medium">{option.filamentEstimate}</span>
|
||||
</div>
|
||||
)}
|
||||
{option.hardwareCost !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-500">Hardware:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{formatPrice(option.hardwareCost)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSubSection = (mainSectionId, subSectionId, subSection) => {
|
||||
const subSectionKey = `${mainSectionId}.${subSectionId}`;
|
||||
const selectedOptions = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
|
||||
const hasSelection = selectedOptions.length > 0;
|
||||
const isExpanded = expandedSubSections[subSectionKey] !== false && (!hasSelection || expandedSubSections[subSectionKey] === true);
|
||||
|
||||
return (
|
||||
<div key={subSectionId} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSubSection(subSectionKey)}
|
||||
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-semibold text-gray-800">{subSection.title}</h4>
|
||||
{hasSelection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedOptions.map((opt) => opt.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{isExpanded && subSection.options && subSection.options.length > 0 && (
|
||||
<div className="p-4 bg-white">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{subSection.options.map((option) =>
|
||||
renderOptionCard(option, mainSectionId, subSectionId, subSection, subSection.isMultiSelect)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMainSection = (mainSectionId, mainSection) => {
|
||||
const isExpanded = expandedMainSections[mainSectionId] !== false;
|
||||
const subSections = Object.entries(mainSection.sections || {});
|
||||
const isComplete = isMainSectionComplete(mainSectionId, mainSection);
|
||||
|
||||
return (
|
||||
<div key={mainSectionId} className={`border-2 rounded-lg overflow-hidden mb-4 ${
|
||||
isComplete ? 'border-green-500' : 'border-gray-300'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => toggleMainSection(mainSectionId)}
|
||||
className={`w-full px-6 py-4 transition-colors flex items-center justify-between ${
|
||||
isComplete
|
||||
? 'bg-green-50 hover:bg-green-100'
|
||||
: 'bg-gray-100 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className={`text-xl font-bold ${
|
||||
isComplete ? 'text-green-900' : 'text-gray-900'
|
||||
}`}>
|
||||
{mainSection.title}
|
||||
</h3>
|
||||
{isComplete && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-green-700">Complete</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
} ${isComplete ? 'text-green-600' : 'text-gray-600'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4 bg-white">
|
||||
{subSections.map(([subSectionId, subSection]) =>
|
||||
renderSubSection(mainSectionId, subSectionId, subSection)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mainSections = partsData.options ? Object.entries(partsData.options) : [];
|
||||
|
||||
// Filter sections and options for upgrade mode
|
||||
const getFilteredSections = () => {
|
||||
if (buildType !== 'upgrade') {
|
||||
return mainSections;
|
||||
}
|
||||
|
||||
// In upgrade mode, only show sections with mod components
|
||||
return mainSections
|
||||
.map(([mainSectionId, mainSection]) => {
|
||||
// Filter sub-sections to only show those with mod options
|
||||
const filteredSubSections = {};
|
||||
Object.entries(mainSection.sections || {}).forEach(([subSectionId, subSection]) => {
|
||||
// Check if this sub-section has mod options
|
||||
const hasModOptions = subSection.options?.some(opt => opt.type === 'mod') ||
|
||||
subSection.componentType === 'mod';
|
||||
|
||||
if (hasModOptions) {
|
||||
// Filter options to only show mods
|
||||
const modOptions = subSection.options?.filter(opt => opt.type === 'mod') || [];
|
||||
if (modOptions.length > 0 || subSection.componentType === 'mod') {
|
||||
filteredSubSections[subSectionId] = {
|
||||
...subSection,
|
||||
options: modOptions.length > 0 ? modOptions : subSection.options,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only include main section if it has any filtered sub-sections
|
||||
if (Object.keys(filteredSubSections).length > 0) {
|
||||
return [mainSectionId, { ...mainSection, sections: filteredSubSections }];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const filteredSections = getFilteredSections();
|
||||
|
||||
// Filter out toyMounts and remoteControl sections (now in their own steps)
|
||||
const sectionsToRender = filteredSections.filter(([mainSectionId]) => mainSectionId !== 'toyMounts' && mainSectionId !== 'remoteControl');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
{buildType === 'upgrade' ? 'Select Upgrades & Modifications' : 'Select Options'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{buildType === 'upgrade'
|
||||
? 'Choose upgrade components and modifications for your existing build.'
|
||||
: 'Choose your preferred mounting options and accessories.'}
|
||||
</p>
|
||||
|
||||
{sectionsToRender.length === 0 && buildType === 'upgrade' && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800">
|
||||
No upgrade components available. All components are base modules.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{sectionsToRender.map(([mainSectionId, mainSection]) =>
|
||||
renderMainSection(mainSectionId, mainSection)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
website/src/components/steps/PowerSupplyStep.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
|
||||
export default function PowerSupplyStep({ config, updateConfig }) {
|
||||
const selectedPowerSupplyId = config.powerSupply?.id;
|
||||
const selectedMotorId = config.motor?.id;
|
||||
|
||||
const handleSelect = (powerSupply) => {
|
||||
updateConfig({ powerSupply });
|
||||
};
|
||||
|
||||
// Filter compatible power supplies
|
||||
const compatiblePowerSupplies = partsData.powerSupplies.filter((psu) => {
|
||||
if (!selectedMotorId) return true;
|
||||
return psu.compatibleMotors.includes(selectedMotorId);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Select Power Supply</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Choose a compatible power supply for your selected motor.
|
||||
</p>
|
||||
|
||||
{selectedMotorId && (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
Showing power supplies compatible with:{' '}
|
||||
<span className="font-semibold">
|
||||
{config.motor?.name || 'Selected Motor'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{compatiblePowerSupplies.map((powerSupply) => (
|
||||
<button
|
||||
key={powerSupply.id}
|
||||
onClick={() => handleSelect(powerSupply)}
|
||||
className={`p-6 border-2 rounded-lg text-left transition-all ${
|
||||
selectedPowerSupplyId === powerSupply.id
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{powerSupply.image && (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src={powerSupply.image}
|
||||
alt={powerSupply.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{powerSupply.name}
|
||||
</h3>
|
||||
{selectedPowerSupplyId === powerSupply.id && (
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
{powerSupply.description}
|
||||
</p>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Voltage:</span>{' '}
|
||||
<span className="font-medium">{powerSupply.voltage}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Current:</span>{' '}
|
||||
<span className="font-medium">{powerSupply.current}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-lg font-bold text-blue-600">
|
||||
{formatPrice(powerSupply.price)}
|
||||
</div>
|
||||
</div>
|
||||
{powerSupply.links && powerSupply.links.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-500 mb-2">Buy from:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{powerSupply.links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 hover:text-blue-800 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 mr-1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{link.store}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
website/src/components/steps/RemoteStep.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
|
||||
export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
const [expandedKnobs, setExpandedKnobs] = useState(false);
|
||||
|
||||
const availableRemotes = [
|
||||
{
|
||||
id: 'ossm-remote-standard',
|
||||
name: 'OSSM - Remote',
|
||||
description: 'Standard OSSM remote (can be purchased from RAD or self-sourced with PCB Way)',
|
||||
radrOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'ossm-remote-radr',
|
||||
name: 'OSSM - RADR',
|
||||
description: 'RADR remote system (RAD only)',
|
||||
radrOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Show all available remotes for both build types
|
||||
const getAvailableRemotes = () => {
|
||||
return availableRemotes;
|
||||
};
|
||||
|
||||
const selectedRemoteId = config.remoteType || config.remote?.id;
|
||||
const selectedRemotePCB = config.remotePCB || null;
|
||||
|
||||
const handleRemoteSelect = (remoteId) => {
|
||||
// Consolidate all updates into a single call to prevent state issues
|
||||
const updates = {
|
||||
remoteType: remoteId,
|
||||
};
|
||||
|
||||
// Reset PCB selection when switching remotes
|
||||
if (remoteId === 'ossm-remote-radr') {
|
||||
// RADR only available from RAD
|
||||
updates.remotePCB = 'rad';
|
||||
} else {
|
||||
updates.remotePCB = null;
|
||||
}
|
||||
|
||||
// Clear knob selection when switching remotes
|
||||
updates.remoteKnob = null;
|
||||
|
||||
updateConfig(updates);
|
||||
};
|
||||
|
||||
const handlePCBSelect = (source) => {
|
||||
updateConfig({ remotePCB: source });
|
||||
};
|
||||
|
||||
const handleKnobSelect = (knob) => {
|
||||
updateConfig({ remoteKnob: knob });
|
||||
};
|
||||
|
||||
const getSelectedRemoteSystem = () => {
|
||||
if (!selectedRemoteId) return null;
|
||||
return partsData.components?.remotes?.systems?.[selectedRemoteId] || null;
|
||||
};
|
||||
|
||||
const getAvailableKnobs = () => {
|
||||
const remoteSystem = getSelectedRemoteSystem();
|
||||
if (!remoteSystem || !remoteSystem.knobs) return [];
|
||||
|
||||
return remoteSystem.knobs.map((knob) => ({
|
||||
id: knob.id,
|
||||
name: knob.name,
|
||||
description: knob.description,
|
||||
filamentEstimate: knob.filamentEstimate !== undefined ? `~${knob.filamentEstimate}g` : "0g",
|
||||
timeEstimate: knob.timeEstimate,
|
||||
colour: knob.colour,
|
||||
}));
|
||||
};
|
||||
|
||||
const isKnobSelected = (knobId) => {
|
||||
return config.remoteKnob?.id === knobId;
|
||||
};
|
||||
|
||||
const renderRemoteCard = (remote) => {
|
||||
const isSelected = selectedRemoteId === remote.id;
|
||||
const remoteSystem = partsData.components?.remotes?.systems?.[remote.id];
|
||||
const imagePath = remoteSystem?.image ? `${remoteSystem.image}` : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={remote.id}
|
||||
onClick={() => handleRemoteSelect(remote.id)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{imagePath && (
|
||||
<div className="mb-3 flex justify-center">
|
||||
<img
|
||||
src={imagePath}
|
||||
alt={remote.name}
|
||||
className="h-48 w-48 object-contain rounded-lg bg-gray-100"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">
|
||||
{remote.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">{remote.description}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPCBSelection = () => {
|
||||
if (!selectedRemoteId || selectedRemoteId === 'ossm-remote-radr') {
|
||||
// RADR only available from RAD, so no selection needed
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">PCB Purchase Source</h3>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => handlePCBSelect('rad')}
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${
|
||||
selectedRemotePCB === 'rad'
|
||||
? 'border-blue-600 bg-blue-50 text-blue-900 font-medium'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Purchase from RAD
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePCBSelect('pcbway')}
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${
|
||||
selectedRemotePCB === 'pcbway'
|
||||
? 'border-blue-600 bg-blue-50 text-blue-900 font-medium'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Self-source with PCBWay
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderKnobCard = (knob) => {
|
||||
const isSelected = isKnobSelected(knob.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={knob.id}
|
||||
onClick={() => handleKnobSelect(knob)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">
|
||||
{knob.name}
|
||||
</h4>
|
||||
{knob.description && (
|
||||
<p className="text-sm text-gray-600">{knob.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm mt-3">
|
||||
{knob.filamentEstimate && (
|
||||
<div>
|
||||
<span className="text-gray-500">Filament:</span>{' '}
|
||||
<span className="font-medium">{knob.filamentEstimate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const availableRemotesFiltered = getAvailableRemotes();
|
||||
const availableKnobs = getAvailableKnobs();
|
||||
const hasRemoteSelected = !!selectedRemoteId;
|
||||
const hasKnobSelected = !!config.remoteKnob;
|
||||
|
||||
// Auto-expand knobs section when remote is selected
|
||||
useEffect(() => {
|
||||
if (hasRemoteSelected && availableKnobs.length > 0) {
|
||||
setExpandedKnobs(true);
|
||||
}
|
||||
}, [hasRemoteSelected, availableKnobs.length]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Select Remote Control</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Choose your remote control system and knob option.
|
||||
</p>
|
||||
|
||||
{/* Remote Selection */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">Remote System</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{availableRemotesFiltered.map((remote) => renderRemoteCard(remote))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PCB Purchase Source (only for OSSM - Remote) */}
|
||||
{hasRemoteSelected && renderPCBSelection()}
|
||||
|
||||
{/* Knobs Selection (only shown when remote is selected) */}
|
||||
{hasRemoteSelected && availableKnobs.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedKnobs(!expandedKnobs)}
|
||||
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Remote Knobs</h3>
|
||||
{hasKnobSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{config.remoteKnob?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
expandedKnobs ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedKnobs && (
|
||||
<div className="p-4 bg-white">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{availableKnobs.map((knob) => renderKnobCard(knob))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasRemoteSelected && (
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
<strong>Note:</strong> Please select a remote control system to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
website/src/components/steps/ToyMountStep.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
|
||||
export default function ToyMountStep({ config, updateConfig }) {
|
||||
const [expandedSubSections, setExpandedSubSections] = useState({});
|
||||
|
||||
const handleToyMountToggle = (option) => {
|
||||
const currentToyMounts = config.toyMountOptions || [];
|
||||
const isSelected = currentToyMounts.some((opt) => opt.id === option.id);
|
||||
|
||||
if (isSelected) {
|
||||
updateConfig({
|
||||
toyMountOptions: currentToyMounts.filter((opt) => opt.id !== option.id),
|
||||
});
|
||||
} else {
|
||||
updateConfig({
|
||||
toyMountOptions: [...currentToyMounts, option],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSubSection = (subSectionKey) => {
|
||||
setExpandedSubSections((prev) => ({
|
||||
...prev,
|
||||
[subSectionKey]: !prev[subSectionKey],
|
||||
}));
|
||||
};
|
||||
|
||||
const getSelectedOptionsForSubSection = (subSectionId, subSection) => {
|
||||
const allToyMountOptions = config.toyMountOptions || [];
|
||||
const subsectionOptionIds = new Set(subSection.options.map(opt => opt.id));
|
||||
return allToyMountOptions.filter(opt => subsectionOptionIds.has(opt.id));
|
||||
};
|
||||
|
||||
const isOptionSelected = (option, subSectionId, subSection) => {
|
||||
const selected = getSelectedOptionsForSubSection(subSectionId, subSection);
|
||||
return selected.some((opt) => opt.id === option.id);
|
||||
};
|
||||
|
||||
const handleOptionClick = (option, subSectionId, subSection) => {
|
||||
handleToyMountToggle(option);
|
||||
};
|
||||
|
||||
const renderOptionCard = (option, subSectionId, subSection) => {
|
||||
const isSelected = isOptionSelected(option, subSectionId, subSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleOptionClick(option, subSectionId, subSection)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{option.image && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className="h-24 w-24 object-contain rounded-lg bg-gray-100"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">
|
||||
{option.name}
|
||||
</h4>
|
||||
{option.description && (
|
||||
<p className="text-sm text-gray-600">{option.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||
<span className="text-white text-sm font-bold">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm mt-3">
|
||||
{option.filamentEstimate && (
|
||||
<div>
|
||||
<span className="text-gray-500">Filament:</span>{' '}
|
||||
<span className="font-medium">{option.filamentEstimate}</span>
|
||||
</div>
|
||||
)}
|
||||
{option.hardwareCost !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-500">Hardware:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{formatPrice(option.hardwareCost)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSubSection = (subSectionId, subSection) => {
|
||||
const subSectionKey = `toyMounts.${subSectionId}`;
|
||||
const selectedOptions = getSelectedOptionsForSubSection(subSectionId, subSection);
|
||||
const hasSelection = selectedOptions.length > 0;
|
||||
const isExpanded = expandedSubSections[subSectionKey] !== false && (!hasSelection || expandedSubSections[subSectionKey] === true);
|
||||
|
||||
return (
|
||||
<div key={subSectionId} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSubSection(subSectionKey)}
|
||||
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-semibold text-gray-800">{subSection.title}</h4>
|
||||
{hasSelection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedOptions.map((opt) => opt.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{isExpanded && subSection.options && subSection.options.length > 0 && (
|
||||
<div className="p-4 space-y-3 bg-white">
|
||||
{subSection.options.map((option) =>
|
||||
renderOptionCard(option, subSectionId, subSection)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const toyMountsSection = partsData.options?.toyMounts;
|
||||
const subSections = toyMountsSection ? Object.entries(toyMountsSection.sections || {}) : [];
|
||||
const hasSelection = (config.toyMountOptions || []).length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Select Toy Mounts</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Choose your preferred toy mount options. You can select multiple options from different categories.
|
||||
</p>
|
||||
|
||||
{subSections.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{subSections.map(([subSectionId, subSection]) =>
|
||||
renderSubSection(subSectionId, subSection)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSelection && (
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
<strong>Note:</strong> At least one toy mount option is recommended for your build.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
website/src/data/colors.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"primary": [
|
||||
{
|
||||
"id": "black",
|
||||
"name": "Black",
|
||||
"hex": "#000000"
|
||||
},
|
||||
{
|
||||
"id": "white",
|
||||
"name": "White",
|
||||
"hex": "#FFFFFF"
|
||||
},
|
||||
{
|
||||
"id": "red",
|
||||
"name": "Red",
|
||||
"hex": "#EF4444"
|
||||
},
|
||||
{
|
||||
"id": "blue",
|
||||
"name": "Blue",
|
||||
"hex": "#3B82F6"
|
||||
},
|
||||
{
|
||||
"id": "purple",
|
||||
"name": "Purple",
|
||||
"hex": "#A855F7"
|
||||
},
|
||||
{
|
||||
"id": "pink",
|
||||
"name": "Pink",
|
||||
"hex": "#EC4899"
|
||||
}
|
||||
],
|
||||
"accent": [
|
||||
{
|
||||
"id": "black",
|
||||
"name": "Black",
|
||||
"hex": "#000000"
|
||||
},
|
||||
{
|
||||
"id": "white",
|
||||
"name": "White",
|
||||
"hex": "#FFFFFF"
|
||||
},
|
||||
{
|
||||
"id": "red",
|
||||
"name": "Red",
|
||||
"hex": "#EF4444"
|
||||
},
|
||||
{
|
||||
"id": "blue",
|
||||
"name": "Blue",
|
||||
"hex": "#3B82F6"
|
||||
},
|
||||
{
|
||||
"id": "purple",
|
||||
"name": "Purple",
|
||||
"hex": "#A855F7"
|
||||
},
|
||||
{
|
||||
"id": "pink",
|
||||
"name": "Pink",
|
||||
"hex": "#EC4899"
|
||||
},
|
||||
{
|
||||
"id": "gold",
|
||||
"name": "Gold",
|
||||
"hex": "#F59E0B"
|
||||
},
|
||||
{
|
||||
"id": "silver",
|
||||
"name": "Silver",
|
||||
"hex": "#9CA3AF"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
website/src/data/compatibility.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"type": "mountToStandCrossbar",
|
||||
"description": "Only 1 mount option can be selected",
|
||||
"check": "mountOptions.length === 1"
|
||||
},
|
||||
{
|
||||
"type": "hingeToStandCrossbar",
|
||||
"description": "Only 1 hinge option can be selected",
|
||||
"check": "hingeOptions.length === 1"
|
||||
},
|
||||
{
|
||||
"type": "standCrossbarSupportToStandCrossbar",
|
||||
"description": "Only 1 stand crossbar support option can be selected",
|
||||
"check": "standCrossbarSupportOptions.length === 1"
|
||||
}
|
||||
],
|
||||
"requiredParts": {
|
||||
"motor": true,
|
||||
"powerSupply": true,
|
||||
"actuatorMount": true,
|
||||
"pcbMount": true
|
||||
},
|
||||
"optionalParts": {
|
||||
"toyMounts": false,
|
||||
"standParts": false
|
||||
}
|
||||
}
|
||||
188
website/src/data/components/actuator.json
Normal file
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"actuator": {
|
||||
"category": "Actuator",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-actuator-body-bottom",
|
||||
"name": "Actuator Bottom",
|
||||
"description": "Actuator bottom part",
|
||||
"filamentEstimate": 56.7,
|
||||
"timeEstimate": "2h14m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Bottom.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Bottom.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle",
|
||||
"name": "Actuator Middle",
|
||||
"description": "Actuator middle part",
|
||||
"filamentEstimate": 65.69,
|
||||
"timeEstimate": "2h23m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Middle.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Middle.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-cover",
|
||||
"name": "Actuator Cover",
|
||||
"description": "Actuator cover part",
|
||||
"filamentEstimate": 27.61,
|
||||
"timeEstimate": "1h3m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Cover.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-belt-tensioner",
|
||||
"name": "Belt Tensioner",
|
||||
"description": "Belt tensioner part",
|
||||
"filamentEstimate": 10.51,
|
||||
"timeEstimate": "40m25s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Belt Tensioner.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Belt%20Tensioner.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
"name": "24mm Clamping Thread Belt Clamp",
|
||||
"description": "24mm clamping thread part",
|
||||
"filamentEstimate": 2.01,
|
||||
"timeEstimate": "19m36s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 24mm Clamping Thread Belt Clamp.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20Belt%20Clamp.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-end-effector",
|
||||
"name": "24mm Clamping Thread End Effector",
|
||||
"description": "24mm clamping thread end effector part",
|
||||
"filamentEstimate": 18.52,
|
||||
"timeEstimate": "1h20m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 24mm Clamping Thread End Effector.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20End%20Effector.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-nut-5-sided",
|
||||
"name": "24mm Nut 5 Sided",
|
||||
"description": "24mm nut 5 sided part",
|
||||
"filamentEstimate": 5.12,
|
||||
"timeEstimate": "21m10s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 24mm Nut 5 Sided.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Nut%20-%205%20Sided.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m3x8-shcs",
|
||||
"required": true,
|
||||
"quantity": 8,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m3x16-shcs",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-6-sided"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m3x20-shcs",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-5-sided"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m3-hex-nut",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-hex"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5-hex-nut",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x20-shcs",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom",
|
||||
"ossm-actuator-body-middle",
|
||||
"ossm-actuator-body-middle-pivot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x35-shcs",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-shcs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-hex"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-gt2-pulley",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-gt2-belt",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-clamping-thread-belt-clamp",
|
||||
"ossm-belt-tensioner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-mgn12h-linear-rail",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"relatedParts": [
|
||||
"ossm-gt2-belt-clamp",
|
||||
"ossm-24mm-nut-shcs",
|
||||
"ossm-actuator-body-bottom"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-bearing-MR115-2RS 5x11x4mm",
|
||||
"required": true,
|
||||
"quantity": 6,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
138
website/src/data/components/mounting.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"pitClamp": {
|
||||
"category": "PitClamp",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-lower",
|
||||
"name": "PitClamp Mini Lower",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 49.45,
|
||||
"timeEstimate": "1h55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Lower V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Lower%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-upper",
|
||||
"name": "PitClamp Mini Upper",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 27.36,
|
||||
"timeEstimate": "1h11m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Upper V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Upper%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-57AIM30",
|
||||
"name": "PitClamp Mini 57AIM30",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 46.03,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - 57AIM30 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2057AIM%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-42AIM30",
|
||||
"name": "PitClamp Mini 42AIM30",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 46.03,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - 42AIM30 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2042AIM%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-iHSV57",
|
||||
"name": "PitClamp Mini iHSV57",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 46.03,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - iHSV57 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%20iHSV57.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-handle",
|
||||
"name": "PitClamp Mini Handle",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 9.23,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Handle - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Handle.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-nuts",
|
||||
"name": "PitClamp Mini Dogbone Nuts",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 4.44,
|
||||
"timeEstimate": "20m49s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Dogbone Nuts - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Nuts.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-bolts ",
|
||||
"name": "PitClamp Mini Dogbone Bolts",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 4.44,
|
||||
"timeEstimate": "20m49s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Dogbone Bolts - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Bolts.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "pitclamp-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"middlePivot": {
|
||||
"category": "Middle Pivot",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-actuator-body-middle-pivot",
|
||||
"name": "Actuator Body Middle Pivot",
|
||||
"description": "Middle Pivot mounting system",
|
||||
"filamentEstimate": 147.19,
|
||||
"timeEstimate": "5h8m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Middle Pivot.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/Non-standard/OSSM%20-%20Actuator%20-%20Body%20-%20Middle%20Pivot.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-handle-spacer",
|
||||
"name": "Handle Spacer",
|
||||
"description": "Handle spacer part",
|
||||
"filamentEstimate": 0,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Handle Spacer.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "middle-pivot-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
108
website/src/data/components/remote.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"remotes": {
|
||||
"category": "Remote",
|
||||
"type": "mod",
|
||||
"systems": {
|
||||
"ossm-remote-standard": {
|
||||
"name": "OSSM Remote Standard",
|
||||
"description": "Standard OSSM remote system",
|
||||
"image": "/images/remote/standard-remote.png",
|
||||
"bodyParts": [
|
||||
{
|
||||
"id": "ossm-remote-body",
|
||||
"name": "Remote Body",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 23.39,
|
||||
"timeEstimate": "53m42s",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-body.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Body.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-top-cover",
|
||||
"name": "Remote Top Cover",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 12.37,
|
||||
"timeEstimate": "37m32s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-top-cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Top%20Cover.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"knobs": [
|
||||
{
|
||||
"id": "ossm-remote-knob",
|
||||
"name": "Remote Knob",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 20.79,
|
||||
"timeEstimate": "1h14m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-knob.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Knob%20-%20Rounded.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple",
|
||||
"name": "Remote Knob Simple",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 20.79,
|
||||
"timeEstimate": "1h14m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-knob-simple.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/tree/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
"name": "Remote Knob Simple With Position Indicator",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 0,
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-simple-with-position-indicator.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple%20With%20Position%20Indicator.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled",
|
||||
"name": "Remote Knob Knurled",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 0,
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-knurled.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
"name": "Remote Knob Knurled With Position Indicator",
|
||||
"description": "Remote system",
|
||||
"filamentEstimate": 0,
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-knurled-with-position-indicator.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled%20With%20Position%20Indicator.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "remote-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"ossm-remote-radr": {
|
||||
"name": "OSSM - RADR",
|
||||
"description": "RADR remote system (RAD only)",
|
||||
"image": "/images/remote/radr-remote.png",
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "remote-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
212
website/src/data/components/stand.json
Normal file
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"hinges": {
|
||||
"category": "Hinges",
|
||||
"type": "mod",
|
||||
"systems": {
|
||||
"pivot-plate": {
|
||||
"name": "Pivot Plate",
|
||||
"description": "Pivot plate for the stand",
|
||||
"image": "/images/options/pivot-plate.webp",
|
||||
"hardwareCost": 10,
|
||||
"price": 0,
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "pivot-plate",
|
||||
"name": "Pivot Plate Left",
|
||||
"description": "Pivot plate for the stand",
|
||||
"filamentEstimate": 150,
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Plate.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Left.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate-right",
|
||||
"name": "Pivot Plate Right",
|
||||
"description": "Pivot plate for the stand",
|
||||
"filamentEstimate": 150,
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Plate.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Right.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m6x25-shcs",
|
||||
"required": true,
|
||||
"quantity": 6
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"required": true,
|
||||
"quantity": 6
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-washer",
|
||||
"required": true,
|
||||
"quantity": 6
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6x25-handle",
|
||||
"required": true,
|
||||
"quantity": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"pitclamp-reinforced-3030": {
|
||||
"name": "PitClamp Reinforced 3030 Hinges",
|
||||
"description": "Reinforced 3030 hinges for PitClamp",
|
||||
"image": "/images/options/pitclamp-reinforced-3030-hinges.jpg",
|
||||
"hardwareCost": 15,
|
||||
"price": 0,
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "pitclamp-reinforced-3030",
|
||||
"name": "PitClamp Reinforced 3030 Hinges",
|
||||
"description": "Reinforced 3030 hinges for PitClamp",
|
||||
"filamentEstimate": 200,
|
||||
"colour": "primary",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "pitclamp-reinforced-3030-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"feet": {
|
||||
"category": "Feet",
|
||||
"type": "mod",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "standard-feet",
|
||||
"name": "Standard",
|
||||
"description": "Standard feet",
|
||||
"filamentEstimate": 50,
|
||||
"image": "/images/options/standard-feet.jpg",
|
||||
"hardwareCost": 0,
|
||||
"price": 0,
|
||||
"colour": "secondary",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "suction-feet",
|
||||
"name": "Suction",
|
||||
"description": "Suction feet for better stability",
|
||||
"filamentEstimate": 60,
|
||||
"image": "/images/options/suction-feet.jpg",
|
||||
"hardwareCost": 5,
|
||||
"price": 0,
|
||||
"colour": "secondary",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m6x12-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"standard-feet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"standard-feet"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"caps": {
|
||||
"category": "Caps",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-3030-cap",
|
||||
"name": "3030 Cap",
|
||||
"description": "Cap mounting system",
|
||||
"filamentEstimate": 10,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 3030 Cap.stl",
|
||||
"quantity": 6,
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Extrusion%20Cap.stl?raw=true"
|
||||
}
|
||||
]
|
||||
},
|
||||
"crossbarSupports": {
|
||||
"category": "Crossbar Supports",
|
||||
"type": "mod",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "standard-90-degree-support",
|
||||
"name": "Standard 90 Degree Support",
|
||||
"description": "Standard 90 degree support for the stand (hardware only)",
|
||||
"filamentEstimate": 0,
|
||||
"image": "/images/options/standard-90-degree-support.jpg",
|
||||
"hardwareCost": 10,
|
||||
"price": "$10.00-$20.00",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"isHardwareOnly": true
|
||||
},
|
||||
{
|
||||
"id": "3d-printed-90-degree-support",
|
||||
"name": "3D Printed 90 Degree Support",
|
||||
"description": "3D printed 90 degree support for the stand",
|
||||
"filamentEstimate": 100,
|
||||
"image": "/images/options/3d-printed-90-degree-support.jpg",
|
||||
"hardwareCost": 2,
|
||||
"price": "$2.00-$4.00",
|
||||
"colour": "secondary",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m6x12-shcs",
|
||||
"required": true,
|
||||
"quantity": 8,
|
||||
"relatedParts": [
|
||||
"3d-printed-90-degree-support",
|
||||
"standard-90-degree-support"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"required": true,
|
||||
"quantity": 8,
|
||||
"relatedParts": [
|
||||
"3d-printed-90-degree-support",
|
||||
"standard-90-degree-support"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-washer",
|
||||
"required": true,
|
||||
"quantity": 8,
|
||||
"relatedParts": [
|
||||
"3d-printed-90-degree-support",
|
||||
"standard-90-degree-support"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-3030-90-degree-support",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"standard-90-degree-support"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
115
website/src/data/components/toyMounts.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"toyMounts": {
|
||||
"category": "Toy Mounts",
|
||||
"type": "mod",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"name": "Toy Mount Flange Base 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 46.26,
|
||||
"timeEstimate": "1h48m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-24mm-threaded.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
|
||||
"name": "Toy Mount Flange Base Dildo Ring 2.5in",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2_5in.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2in",
|
||||
"name": "Toy Mount Flange Base Dildo Ring 2in",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2in.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-24mm-threaded",
|
||||
"name": "Toy Mount Double Double 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-double-double-24mm-threaded.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-rail-mounted",
|
||||
"name": "Toy Mount Double Double Rail Mounted",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-double-double-rail-mounted.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"name": "Toy Mount Sucson Mount Base Plate 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"name": "Toy Mount Sucson Mount Ring Insert 55mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-ring-insert-55mm.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-threaded-ring",
|
||||
"name": "Toy Mount Sucson Mount Threaded Ring",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-threaded-ring.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"name": "Toy Mount Tie Down and Suction Plate 110mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-110mm.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
|
||||
"name": "Toy Mount Tie Down and Suction Plate 135mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-135mm.stl"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "toy-mount-hardware",
|
||||
"required": true,
|
||||
"relatedParts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
148
website/src/data/hardware.json
Normal file
@@ -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
@@ -0,0 +1,250 @@
|
||||
import motors from './motors.json';
|
||||
import powerSupplies from './powerSupplies.json';
|
||||
import optionsData from './options.json';
|
||||
import colors from './colors.json';
|
||||
import hardwareData from './hardware.json';
|
||||
import actuatorComponents from './components/actuator.json';
|
||||
import standComponents from './components/stand.json';
|
||||
import mountingComponents from './components/mounting.json';
|
||||
import toyMountsComponents from './components/toyMounts.json';
|
||||
import remoteComponents from './components/remote.json';
|
||||
|
||||
// Create a hardware lookup map from hardware.json
|
||||
const hardwareLookup = new Map();
|
||||
Object.values(hardwareData).forEach((category) => {
|
||||
Object.values(category).forEach((hardware) => {
|
||||
hardwareLookup.set(hardware.id, hardware);
|
||||
});
|
||||
});
|
||||
|
||||
// Function to resolve hardware references (IDs) to full hardware definitions
|
||||
const resolveHardwareReferences = (components) => {
|
||||
const resolvedComponents = {};
|
||||
|
||||
Object.entries(components).forEach(([componentKey, component]) => {
|
||||
resolvedComponents[componentKey] = { ...component };
|
||||
|
||||
// Resolve hardwareParts if they exist
|
||||
if (component.hardwareParts) {
|
||||
resolvedComponents[componentKey].hardwareParts = component.hardwareParts.map((hw) => {
|
||||
// If it's already a full object, return as-is
|
||||
if (hw.id && hw.name) {
|
||||
return hw;
|
||||
}
|
||||
// If it's a reference (just an ID string or object with hardwareId), resolve it
|
||||
const hardwareId = typeof hw === 'string' ? hw : hw.hardwareId || hw.id;
|
||||
const hardwareDef = hardwareLookup.get(hardwareId);
|
||||
if (!hardwareDef) {
|
||||
console.warn(`Hardware not found: ${hardwareId}`);
|
||||
return hw;
|
||||
}
|
||||
// Merge the base definition with any overrides (quantity, relatedParts, required, etc.)
|
||||
return {
|
||||
...hardwareDef,
|
||||
...(typeof hw === 'object' ? hw : {}),
|
||||
id: hardwareDef.id,
|
||||
name: hardwareDef.name,
|
||||
description: hardwareDef.description,
|
||||
price: hardwareDef.price,
|
||||
// required comes from the component reference, not from hardware.json
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Also resolve hardwareParts in systems
|
||||
if (component.systems) {
|
||||
resolvedComponents[componentKey].systems = {};
|
||||
Object.entries(component.systems).forEach(([systemKey, system]) => {
|
||||
resolvedComponents[componentKey].systems[systemKey] = { ...system };
|
||||
if (system.hardwareParts) {
|
||||
resolvedComponents[componentKey].systems[systemKey].hardwareParts = system.hardwareParts.map((hw) => {
|
||||
if (hw.id && hw.name) {
|
||||
return hw;
|
||||
}
|
||||
const hardwareId = typeof hw === 'string' ? hw : hw.hardwareId || hw.id;
|
||||
const hardwareDef = hardwareLookup.get(hardwareId);
|
||||
if (!hardwareDef) {
|
||||
console.warn(`Hardware not found: ${hardwareId}`);
|
||||
return hw;
|
||||
}
|
||||
return {
|
||||
...hardwareDef,
|
||||
...(typeof hw === 'object' ? hw : {}),
|
||||
id: hardwareDef.id,
|
||||
name: hardwareDef.name,
|
||||
description: hardwareDef.description,
|
||||
price: hardwareDef.price,
|
||||
// required comes from the component reference, not from hardware.json
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return resolvedComponents;
|
||||
};
|
||||
|
||||
// Combine all component files into a single components object
|
||||
const rawComponents = {
|
||||
...actuatorComponents,
|
||||
...standComponents,
|
||||
...mountingComponents,
|
||||
...toyMountsComponents,
|
||||
...remoteComponents,
|
||||
};
|
||||
|
||||
// Resolve hardware references
|
||||
const components = resolveHardwareReferences(rawComponents);
|
||||
|
||||
// Convert component parts to options format
|
||||
const convertComponentPartsToOptions = (componentIds, componentData) => {
|
||||
if (!componentIds || !Array.isArray(componentIds) || componentIds.length === 0) {
|
||||
return componentIds && Array.isArray(componentIds) ? [] : undefined;
|
||||
}
|
||||
|
||||
// Check if componentData has systems (new structure) or printedParts (old structure)
|
||||
if (componentData?.systems) {
|
||||
// Special handling for remotes: flatten knobs from all systems
|
||||
if (componentData.category === 'Remote' && componentIds.length > 0) {
|
||||
// This is for remote knobs - flatten all knobs from all systems
|
||||
const allKnobs = [];
|
||||
Object.values(componentData.systems).forEach((system) => {
|
||||
if (system.knobs) {
|
||||
system.knobs.forEach((knob) => {
|
||||
if (componentIds.includes(knob.id)) {
|
||||
allKnobs.push({
|
||||
id: knob.id,
|
||||
name: knob.name,
|
||||
description: knob.description,
|
||||
image: knob.image || `/images/options/${knob.id}.jpg`,
|
||||
filamentEstimate: knob.filamentEstimate !== undefined ? `~${knob.filamentEstimate}g` : "0g",
|
||||
hardwareCost: system.hardwareCost !== undefined ? system.hardwareCost : 0,
|
||||
price: system.price !== undefined ? system.price : 0,
|
||||
timeEstimate: knob.timeEstimate,
|
||||
colour: knob.colour,
|
||||
type: componentData.type || 'base',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return allKnobs;
|
||||
}
|
||||
|
||||
// New structure: systems with printedParts and hardwareParts (for hinges, etc.)
|
||||
return componentIds
|
||||
.map((systemId) => {
|
||||
const system = componentData.systems[systemId];
|
||||
if (!system) {
|
||||
console.warn(`Component system not found: ${systemId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate total filament estimate from printed parts
|
||||
const totalFilament = (system.printedParts || system.bodyParts)?.reduce((sum, part) => {
|
||||
return sum + (part.filamentEstimate || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
return {
|
||||
id: systemId,
|
||||
name: system.name,
|
||||
description: system.description,
|
||||
image: system.image || `/images/options/${systemId}.jpg`,
|
||||
filamentEstimate: totalFilament > 0 ? `~${totalFilament}g` : "0g",
|
||||
hardwareCost: system.hardwareCost !== undefined ? system.hardwareCost : 0,
|
||||
price: system.price !== undefined ? system.price : 0,
|
||||
colour: (system.printedParts || system.bodyParts)?.[0]?.colour || 'primary',
|
||||
type: componentData.type || 'base',
|
||||
};
|
||||
})
|
||||
.filter((opt) => opt !== null);
|
||||
}
|
||||
|
||||
// Old structure: printedParts array
|
||||
if (!componentData || !componentData.printedParts) {
|
||||
console.warn(`Component data not found or missing printedParts/systems`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return componentIds
|
||||
.map((componentId) => {
|
||||
const part = componentData.printedParts.find((p) => p.id === componentId);
|
||||
if (!part) {
|
||||
console.warn(`Component part not found: ${componentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
description: part.description,
|
||||
image: part.image || `/images/options/${part.id}.jpg`,
|
||||
filamentEstimate: part.filamentEstimate !== undefined ? `~${part.filamentEstimate}g` : "0g",
|
||||
hardwareCost: part.hardwareCost !== undefined ? part.hardwareCost : 0,
|
||||
price: part.price !== undefined ? part.price : 0,
|
||||
timeEstimate: part.timeEstimate,
|
||||
colour: part.colour,
|
||||
type: componentData.type || 'base',
|
||||
};
|
||||
})
|
||||
.filter((opt) => opt !== null);
|
||||
};
|
||||
|
||||
// Merge component options into options
|
||||
const processOptions = (options, componentsData) => {
|
||||
const processedOptions = { ...options };
|
||||
|
||||
// Process each option category
|
||||
Object.keys(processedOptions).forEach((optionKey) => {
|
||||
const optionCategory = processedOptions[optionKey];
|
||||
if (!optionCategory || !optionCategory.sections) return;
|
||||
|
||||
const sections = { ...optionCategory.sections };
|
||||
const categoryUseComponents = optionCategory.useComponents;
|
||||
|
||||
// Convert component parts to options format for each section
|
||||
Object.keys(sections).forEach((sectionKey) => {
|
||||
const section = sections[sectionKey];
|
||||
|
||||
// Check if section has componentIds to process
|
||||
if (section.componentIds !== undefined) {
|
||||
// Determine which component category to use
|
||||
const componentKey = section.useComponents || categoryUseComponents;
|
||||
|
||||
if (componentKey) {
|
||||
const componentData = componentsData[componentKey];
|
||||
const options = convertComponentPartsToOptions(section.componentIds, componentData);
|
||||
// Store component type info for filtering
|
||||
section.componentType = componentData?.type || 'base';
|
||||
section.options = options;
|
||||
} else {
|
||||
console.warn(`No useComponents specified for ${optionKey}.${sectionKey}`);
|
||||
section.options = [];
|
||||
}
|
||||
|
||||
// Clean up temporary properties
|
||||
delete section.componentIds;
|
||||
delete section.useComponents;
|
||||
}
|
||||
});
|
||||
|
||||
processedOptions[optionKey].sections = sections;
|
||||
if (categoryUseComponents) {
|
||||
delete processedOptions[optionKey].useComponents;
|
||||
}
|
||||
});
|
||||
|
||||
return processedOptions;
|
||||
};
|
||||
|
||||
const options = processOptions(optionsData, components);
|
||||
|
||||
export default {
|
||||
motors,
|
||||
powerSupplies,
|
||||
options,
|
||||
colors,
|
||||
components,
|
||||
hardware: hardwareData,
|
||||
};
|
||||
38
website/src/data/motors.json
Normal file
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"id": "57AIM30",
|
||||
"name": "57AIM30 \"Gold Motor\"",
|
||||
"description": "Standard NEMA 17 stepper motor with 1.8° step angle",
|
||||
"speed": "1500 RPM",
|
||||
"wattage": "100W",
|
||||
"gear_count": "RS485",
|
||||
"price": "$125-$250",
|
||||
"image": "/images/motors/57AIM30.png",
|
||||
"required": true,
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "42AIM30",
|
||||
"name": "42AIM30 \"Round Motor\"",
|
||||
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
|
||||
"speed": "1500 RPM",
|
||||
"wattage": "100W",
|
||||
"gear_count": "RS485",
|
||||
"price": "$135-$270",
|
||||
"image": "/images/motors/42AIM30.png",
|
||||
"required": true,
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "iHSV57",
|
||||
"name": "iHSV57 \"Legacy Motor\"",
|
||||
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
|
||||
"speed": "3000 RPM",
|
||||
"wattage": "180W",
|
||||
"gear_count": "RS485",
|
||||
"price": "$150-$300",
|
||||
"image": "/images/motors/iHSV57.png",
|
||||
"required": true,
|
||||
"recommended": false
|
||||
}
|
||||
]
|
||||
123
website/src/data/options.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"actuator": {
|
||||
"title": "Actuator",
|
||||
"sections": {
|
||||
"mounts": {
|
||||
"title": "Mounts",
|
||||
"options": [
|
||||
{
|
||||
"id": "middle-pivot",
|
||||
"name": "Middle Pivot",
|
||||
"description": "Middle Pivot mounting system",
|
||||
"image": "/images/options/middle-pivot.png",
|
||||
"filamentEstimate": "~147g",
|
||||
"type": "base"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp",
|
||||
"name": "PitClamp Mini",
|
||||
"description": "PitClamp Mini mounting system",
|
||||
"image": "/images/options/PitClamp Mini Base.png",
|
||||
"filamentEstimate": "~137g",
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"cover": {
|
||||
"title": "Cover",
|
||||
"options": [
|
||||
{
|
||||
"id": "standard-cover",
|
||||
"name": "Standard Cover",
|
||||
"description": "Standard actuator cover",
|
||||
"image": null,
|
||||
"filamentEstimate": "~27.61g",
|
||||
"type": "base",
|
||||
"componentId": "ossm-actuator-body-cover"
|
||||
},
|
||||
{
|
||||
"id": "blank-cover",
|
||||
"name": "Blank Cover",
|
||||
"description": "Blank cover option",
|
||||
"image": null,
|
||||
"filamentEstimate": "0g",
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"pcbMount": {
|
||||
"title": "PCB Mount",
|
||||
"options": [
|
||||
{
|
||||
"id": "3030-mount",
|
||||
"name": "3030 Mount",
|
||||
"description": "PCB mount for 3030 extrusion",
|
||||
"image": null,
|
||||
"filamentEstimate": null,
|
||||
"type": "base"
|
||||
},
|
||||
{
|
||||
"id": "aio-cover-mount",
|
||||
"name": "AIO Cover Mount",
|
||||
"description": "All-in-one cover mount on the actuator",
|
||||
"image": null,
|
||||
"filamentEstimate": null,
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"stand": {
|
||||
"title": "Stand",
|
||||
"sections": {
|
||||
"hinges": {
|
||||
"title": "Hinges",
|
||||
"useComponents": "hinges",
|
||||
"componentIds": ["pivot-plate", "pitclamp-reinforced-3030"],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"feet": {
|
||||
"title": "Feet",
|
||||
"useComponents": "feet",
|
||||
"componentIds": ["standard-feet", "suction-feet"],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"crossbarSupports": {
|
||||
"title": "Crossbar Supports",
|
||||
"useComponents": "crossbarSupports",
|
||||
"componentIds": ["standard-90-degree-support", "3d-printed-90-degree-support"],
|
||||
"isMultiSelect": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"toyMounts": {
|
||||
"title": "Toy Mounts",
|
||||
"useComponents": "toyMounts",
|
||||
"sections": {
|
||||
"vacULock": {
|
||||
"title": "Vac-U-Lock",
|
||||
"componentIds": ["ossm-toy-mount-double-double-24mm-threaded", "ossm-toy-mount-double-double-rail-mounted"],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"flangeMount": {
|
||||
"title": "Flange Mount",
|
||||
"componentIds": ["ossm-toy-mount-flange-base-24mm-threaded", "ossm-toy-mount-flange-base-dildo-ring-2.5in", "ossm-toy-mount-flange-base-dildo-ring-2in"],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"suCSOn": {
|
||||
"title": "SuCSOn",
|
||||
"componentIds": ["ossm-toy-mount-sucson-mount-base-plate-24mm-threaded", "ossm-toy-mount-sucson-mount-ring-insert-55mm", "ossm-toy-mount-sucson-mount-threaded-ring"],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"tieDown": {
|
||||
"title": "TieDown",
|
||||
"componentIds": ["ossm-toy-mount-tie-down-and-suction-plate-110mm", "ossm-toy-mount-tie-down-and-suction-plate-135mm"],
|
||||
"isMultiSelect": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
website/src/data/powerSupplies.json
Normal file
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
295
website/src/utils/exportUtils.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// Generate markdown overview
|
||||
export const generateMarkdownOverview = (config, printedParts, hardwareParts, filamentTotals, totalTime, total) => {
|
||||
const md = [];
|
||||
|
||||
md.push('# OSSM Build Configuration');
|
||||
md.push(`\n**Generated:** ${new Date().toLocaleString()}\n`);
|
||||
|
||||
// Motor
|
||||
if (config.motor) {
|
||||
md.push(`## Motor: ${config.motor.name}`);
|
||||
md.push(`- **Price:** ${config.motor.price}`);
|
||||
md.push(`- **Speed:** ${config.motor.speed}`);
|
||||
md.push(`- **Wattage:** ${config.motor.wattage}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Power Supply
|
||||
if (config.powerSupply) {
|
||||
md.push(`## Power Supply: ${config.powerSupply.name}`);
|
||||
md.push(`- **Price:** ${config.powerSupply.price}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Colors
|
||||
md.push(`## Colors`);
|
||||
md.push(`- **Primary:** ${config.primaryColor || 'Not selected'}`);
|
||||
md.push(`- **Accent:** ${config.accentColor || 'Not selected'}`);
|
||||
md.push('');
|
||||
|
||||
// Mount
|
||||
if (config.mount) {
|
||||
md.push(`## Mount: ${config.mount.name}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Cover
|
||||
if (config.cover) {
|
||||
md.push(`## Cover: ${config.cover.name}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// PCB Mount
|
||||
if (config.pcbMount) {
|
||||
md.push(`## PCB Mount: ${config.pcbMount.name}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Stand Options
|
||||
if (config.standHinge || config.standFeet || config.standCrossbarSupports?.length > 0) {
|
||||
md.push(`## Stand Options`);
|
||||
if (config.standHinge) md.push(`- **Hinges:** ${config.standHinge.name}`);
|
||||
if (config.standFeet) md.push(`- **Feet:** ${config.standFeet.name}`);
|
||||
if (config.standCrossbarSupports?.length > 0) {
|
||||
md.push(`- **Crossbar Supports:** ${config.standCrossbarSupports.map(s => s.name).join(', ')}`);
|
||||
}
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Remote
|
||||
if (config.remote || config.remoteKnob) {
|
||||
md.push(`## Remote`);
|
||||
if (config.remote) md.push(`- **Type:** ${config.remote.name}`);
|
||||
if (config.remoteKnob) md.push(`- **Knob:** ${config.remoteKnob.name}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Filament Summary
|
||||
if (filamentTotals.total > 0) {
|
||||
md.push(`## Filament Summary`);
|
||||
md.push(`- **Total:** ~${filamentTotals.total.toFixed(2)}g`);
|
||||
if (filamentTotals.primary > 0) md.push(` - Primary: ~${filamentTotals.primary.toFixed(2)}g`);
|
||||
if (filamentTotals.secondary > 0) md.push(` - Accent: ~${filamentTotals.secondary.toFixed(2)}g`);
|
||||
md.push(`- **Estimated Print Time:** ${totalTime}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Print Parts Summary
|
||||
if (printedParts.length > 0) {
|
||||
md.push(`## Printed Parts Summary`);
|
||||
md.push(`- **Total Parts:** ${printedParts.length}`);
|
||||
|
||||
// Group by category
|
||||
const partsByCategory = {};
|
||||
printedParts.forEach(part => {
|
||||
const category = part.category || 'Other';
|
||||
if (!partsByCategory[category]) {
|
||||
partsByCategory[category] = [];
|
||||
}
|
||||
partsByCategory[category].push(part);
|
||||
});
|
||||
|
||||
Object.entries(partsByCategory).forEach(([category, parts]) => {
|
||||
md.push(`### ${category} (${parts.length} parts)`);
|
||||
parts.forEach(part => {
|
||||
md.push(`- ${part.name}${part.filamentEstimate ? ` (~${part.filamentEstimate}g)` : ''}`);
|
||||
});
|
||||
});
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Hardware Summary
|
||||
if (hardwareParts.length > 0) {
|
||||
md.push(`## Hardware Summary`);
|
||||
md.push(`- **Total Items:** ${hardwareParts.length}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// Cost Summary
|
||||
if (total > 0) {
|
||||
md.push(`## Estimated Cost`);
|
||||
md.push(`- **Total:** $${total.toFixed(2)}`);
|
||||
md.push('');
|
||||
}
|
||||
|
||||
return md.join('\n');
|
||||
};
|
||||
|
||||
// Generate Excel BOM with purchase links
|
||||
export const generateExcelBOM = (hardwareParts, printedParts, config) => {
|
||||
const rows = [];
|
||||
|
||||
// Header
|
||||
rows.push(['Item', 'Name', 'Quantity', 'Price', 'Link', 'Category', 'Type']);
|
||||
|
||||
// Add motor
|
||||
if (config.motor) {
|
||||
const motorLinks = config.motor.links || [];
|
||||
const firstLink = motorLinks.length > 0 ? motorLinks[0].link : '';
|
||||
rows.push([
|
||||
'Motor',
|
||||
config.motor.name,
|
||||
1,
|
||||
config.motor.price,
|
||||
firstLink,
|
||||
'Motor',
|
||||
'Hardware'
|
||||
]);
|
||||
}
|
||||
|
||||
// Add power supply
|
||||
if (config.powerSupply) {
|
||||
const psuLinks = config.powerSupply.links || [];
|
||||
const firstLink = psuLinks.length > 0 ? psuLinks[0].link : '';
|
||||
rows.push([
|
||||
'Power Supply',
|
||||
config.powerSupply.name,
|
||||
1,
|
||||
config.powerSupply.price,
|
||||
firstLink,
|
||||
'Power Supply',
|
||||
'Hardware'
|
||||
]);
|
||||
}
|
||||
|
||||
// Add hardware parts
|
||||
hardwareParts.forEach(hw => {
|
||||
const links = hw.links || [];
|
||||
const firstLink = links.length > 0 ? links[0].link : (hw.url || '');
|
||||
rows.push([
|
||||
hw.id || '',
|
||||
hw.name || '',
|
||||
hw.quantity || 1,
|
||||
hw.price ? `$${parseFloat(hw.price).toFixed(2)}` : '',
|
||||
firstLink,
|
||||
hw.category || 'Hardware',
|
||||
'Hardware'
|
||||
]);
|
||||
});
|
||||
|
||||
// Add printed parts (for reference, not purchase)
|
||||
printedParts.forEach(part => {
|
||||
rows.push([
|
||||
part.id || '',
|
||||
part.name || '',
|
||||
1,
|
||||
'N/A',
|
||||
part.url || '',
|
||||
part.category || 'Printed',
|
||||
'Printed Part'
|
||||
]);
|
||||
});
|
||||
|
||||
// Create workbook and worksheet
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
|
||||
// Set column widths
|
||||
ws['!cols'] = [
|
||||
{ wch: 30 }, // Item
|
||||
{ wch: 40 }, // Name
|
||||
{ wch: 10 }, // Quantity
|
||||
{ wch: 12 }, // Price
|
||||
{ wch: 50 }, // Link
|
||||
{ wch: 20 }, // Category
|
||||
{ wch: 15 } // Type
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'BOM');
|
||||
|
||||
return wb;
|
||||
};
|
||||
|
||||
// Generate Excel Print List with completion tracker
|
||||
export const generateExcelPrintList = (printedParts, filamentTotals) => {
|
||||
const rows = [];
|
||||
|
||||
// Header
|
||||
rows.push(['Part Name', 'Category', 'Color', 'Quantity', 'Filament (g)', 'Print Time', 'Status', 'Completed']);
|
||||
|
||||
// Group parts by category and color
|
||||
const partsByCategoryColor = {};
|
||||
printedParts.forEach(part => {
|
||||
const category = part.category || 'Other';
|
||||
const color = part.colour === 'primary' ? 'Primary' : part.colour === 'secondary' ? 'Accent' : 'Other';
|
||||
const key = `${category}_${color}`;
|
||||
|
||||
if (!partsByCategoryColor[key]) {
|
||||
partsByCategoryColor[key] = [];
|
||||
}
|
||||
partsByCategoryColor[key].push(part);
|
||||
});
|
||||
|
||||
// Sort by category, then color
|
||||
const sortedKeys = Object.keys(partsByCategoryColor).sort();
|
||||
|
||||
sortedKeys.forEach(key => {
|
||||
const parts = partsByCategoryColor[key];
|
||||
const [category, color] = key.split('_');
|
||||
|
||||
parts.forEach(part => {
|
||||
const filament = typeof part.filamentEstimate === 'number'
|
||||
? part.filamentEstimate
|
||||
: parseFloat(part.filamentEstimate?.replace('~', '').replace('g', '')) || 0;
|
||||
|
||||
rows.push([
|
||||
part.name || part.id || '',
|
||||
category,
|
||||
color,
|
||||
1,
|
||||
filament > 0 ? filament.toFixed(2) : '',
|
||||
part.timeEstimate || '',
|
||||
'', // Status column (user fills in)
|
||||
'' // Completed column (user checks)
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Add summary row
|
||||
rows.push([]);
|
||||
rows.push(['TOTAL', '', '', printedParts.length, filamentTotals.total.toFixed(2), '', '', '']);
|
||||
|
||||
// Create workbook and worksheet
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
|
||||
// Set column widths
|
||||
ws['!cols'] = [
|
||||
{ wch: 40 }, // Part Name
|
||||
{ wch: 20 }, // Category
|
||||
{ wch: 12 }, // Color
|
||||
{ wch: 10 }, // Quantity
|
||||
{ wch: 15 }, // Filament
|
||||
{ wch: 15 }, // Print Time
|
||||
{ wch: 15 }, // Status
|
||||
{ wch: 12 } // Completed
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Print List');
|
||||
|
||||
// Create a summary sheet with progress calculation
|
||||
// Note: Excel formulas need to reference cells properly
|
||||
const summaryRows = [
|
||||
['Print Progress Summary'],
|
||||
[],
|
||||
['Total Parts', printedParts.length],
|
||||
['Completed Parts', { f: `COUNTIF('Print List'.H:H,"✓")` }],
|
||||
['Progress %', { f: `IF(B3>0, (B4/B3)*100, 0)` }],
|
||||
[],
|
||||
['Filament Summary'],
|
||||
['Total Filament (g)', filamentTotals.total.toFixed(2)],
|
||||
['Primary Color (g)', filamentTotals.primary.toFixed(2)],
|
||||
['Accent Color (g)', (filamentTotals.secondary || 0).toFixed(2)]
|
||||
];
|
||||
|
||||
const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows);
|
||||
summaryWs['!cols'] = [
|
||||
{ wch: 25 },
|
||||
{ wch: 15 }
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary');
|
||||
|
||||
return wb;
|
||||
};
|
||||
26
website/src/utils/priceFormat.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Helper function to format price (handles both number and string prices)
|
||||
export function formatPrice(price) {
|
||||
if (typeof price === 'number') {
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
if (typeof price === 'string') {
|
||||
// If it's already formatted as a string (e.g., "$125-$250"), return as-is
|
||||
return price.startsWith('$') ? price : `$${price}`;
|
||||
}
|
||||
return '$0.00';
|
||||
}
|
||||
|
||||
// Helper function to get numeric price for calculations (returns 0 for string prices)
|
||||
export function getNumericPrice(price) {
|
||||
if (typeof price === 'number') {
|
||||
return price;
|
||||
}
|
||||
if (typeof price === 'string') {
|
||||
// Try to extract a number from string prices like "$125-$250"
|
||||
const match = price.match(/\d+/);
|
||||
if (match) {
|
||||
return parseFloat(match[0]);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
95
website/src/utils/shareService.js
Normal file
@@ -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}`);
|
||||
};
|
||||
11
website/tailwind.config.js
Normal 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
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||