feat: Add prop-types dependency, implement currency context, and enhance pricing display in components

- Added `prop-types` for better prop validation in components.
- Introduced `CurrencyProvider` to manage currency context and preload exchange rates.
- Updated pricing logic in various components to support new price structure and display currency.
- Refactored BOMSummary, MotorStep, and PowerSupplyStep to utilize new pricing methods and improve user experience.
- Enhanced export utilities to format prices correctly in markdown and Excel outputs.
- Updated hardware and component data structures to include detailed pricing information.
This commit is contained in:
MunchDev-oss
2026-01-10 03:04:28 -05:00
parent 86f0acc26b
commit aba0964a59
40 changed files with 3519 additions and 1843 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"exceljs": "^4.4.0",
"jszip": "^3.10.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -4356,7 +4357,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4842,7 +4842,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -4904,8 +4904,7 @@
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-refresh": {
"version": "0.17.0",

View File

@@ -12,6 +12,7 @@
"dependencies": {
"exceljs": "^4.4.0",
"jszip": "^3.10.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@@ -2,14 +2,21 @@ import { useState, useEffect } from 'react';
import MainPage from './components/MainPage';
import Wizard from './components/Wizard';
import ThemeToggle from './components/ThemeToggle';
import CurrencySwitcher from './components/CurrencySwitcher';
import partsData from './data/index.js';
import { getSharedConfig } from './utils/shareService';
function App() {
const [buildType, setBuildType] = useState(null);
// Determine initial recommended parts
const recommendedMotor = partsData.motors.find(m => m.recommended) || partsData.motors[0];
const recommendedPSU = partsData.powerSupplies.find(psu =>
psu.compatibleMotors.includes(recommendedMotor.id)
) || partsData.powerSupplies[0];
const [config, setConfig] = useState({
motor: null,
powerSupply: null,
motor: recommendedMotor,
powerSupply: recommendedPSU,
primaryColor: 'black',
accentColor: 'black',
mount: null,
@@ -29,7 +36,7 @@ function App() {
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) {
@@ -63,10 +70,10 @@ function App() {
// - 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;
@@ -127,9 +134,14 @@ function App() {
const handleBackToMain = () => {
setBuildType(null);
const defaultMotor = partsData.motors.find(m => m.recommended) || partsData.motors[0];
const defaultPSU = partsData.powerSupplies.find(psu =>
psu.compatibleMotors.includes(defaultMotor.id)
) || partsData.powerSupplies[0];
setConfig({
motor: null,
powerSupply: null,
motor: defaultMotor,
powerSupply: defaultPSU,
primaryColor: 'black',
accentColor: 'black',
mount: null,
@@ -149,6 +161,7 @@ function App() {
return (
<>
<ThemeToggle />
<CurrencySwitcher />
<MainPage onSelectBuildType={handleSelectBuildType} />
</>
);
@@ -157,6 +170,7 @@ function App() {
return (
<>
<ThemeToggle />
<CurrencySwitcher />
<Wizard
buildType={buildType}
initialConfig={config}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import DataTable from '../ui/DataTable';
import AsyncPrice from '../ui/AsyncPrice';
/**
* Hardware table row renderer for unified view with currency conversion
*/
const renderUnifiedHardwareRow = (part) => {
// Handle item references (motor, PSU, PCB) - convert from links
const priceToDisplay = part.price;
const hasPrice = priceToDisplay && (
(typeof priceToDisplay === 'object' && (priceToDisplay.links || priceToDisplay.amount)) ||
(typeof priceToDisplay === 'number' && priceToDisplay > 0) ||
(typeof priceToDisplay === 'string' && priceToDisplay !== '$0.00' && priceToDisplay !== 'C$0.00' && priceToDisplay !== '0.00')
);
return (
<tr key={part.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3 whitespace-nowrap">
<p className="text-sm font-medium text-gray-900 dark:text-white">{part.name}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600 dark:text-gray-300">{part.description || '-'}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{part.quantity || 1}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
{hasPrice ? (
<AsyncPrice
price={priceToDisplay}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
fallback="-"
/>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
)}
</td>
</tr>
);
};
/**
* Hardware table row renderer for expanded view with currency conversion
*/
const renderExpandedHardwareRow = (part) => {
// Handle item references (motor, PSU, PCB) - convert from links
const priceToDisplay = part.price;
const hasPrice = priceToDisplay && (
(typeof priceToDisplay === 'object' && (priceToDisplay.links || priceToDisplay.amount)) ||
(typeof priceToDisplay === 'number' && priceToDisplay > 0) ||
(typeof priceToDisplay === 'string' && priceToDisplay !== '$0.00' && priceToDisplay !== 'C$0.00' && priceToDisplay !== '0.00')
);
return (
<tr key={part.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3 whitespace-nowrap">
<p className="text-sm font-medium text-gray-900 dark:text-white">{part.name}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600 dark:text-gray-300">{part.description || '-'}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
{part.hardwareType || 'Other Hardware'}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{part.quantity || 1}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
{hasPrice ? (
<AsyncPrice
price={priceToDisplay}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
fallback="-"
/>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
)}
</td>
</tr>
);
};
/**
* Hardware tab component for BOM Summary
*/
export default function HardwareTab({ hardwareParts, expandedHardwareByComponent }) {
const [hardwareViewMode, setHardwareViewMode] = useState('unified'); // 'unified' or 'expanded'
// Group hardware parts by type for unified view
const hardwareByType = hardwareParts.reduce((acc, part) => {
const type = part.hardwareType || 'Other Hardware';
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(part);
return acc;
}, {});
const unifiedColumns = [
{ key: 'name', label: 'Part Name', align: 'left' },
{ key: 'description', label: 'Description', align: 'left' },
{ key: 'quantity', label: 'Quantity', align: 'right' },
{ key: 'price', label: 'Price', align: 'right' },
];
const expandedColumns = [
{ key: 'name', label: 'Part Name', align: 'left' },
{ key: 'description', label: 'Description', align: 'left' },
{ key: 'type', label: 'Type', align: 'left' },
{ key: 'quantity', label: 'Quantity', align: 'right' },
{ key: 'price', label: 'Price', align: 'right' },
];
// Sort order for hardware types
const sortHardwareTypes = (a, b) => {
const order = ['Fasteners', 'Motion Components', 'Aluminum Extrusion', 'Electronics', 'Other Hardware'];
const indexA = order.indexOf(a);
const indexB = order.indexOf(b);
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
};
if (hardwareParts.length === 0) {
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<p>No hardware parts required for this configuration.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Required Hardware Parts</h3>
<div className="flex gap-2">
<button
onClick={() => setHardwareViewMode('unified')}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${hardwareViewMode === 'unified'
? 'bg-blue-600 dark:bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Unified View
</button>
<button
onClick={() => setHardwareViewMode('expanded')}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${hardwareViewMode === 'expanded'
? 'bg-blue-600 dark:bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Expanded View
</button>
</div>
</div>
</div>
<div className="space-y-4">
{hardwareViewMode === 'unified' ? (
// Unified view: Group by hardware type
Object.entries(hardwareByType)
.sort(([a], [b]) => sortHardwareTypes(a, b))
.map(([type, parts]) => (
<div key={type}>
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300 mb-3">{type}</h4>
<DataTable
columns={unifiedColumns}
data={parts}
renderRow={renderUnifiedHardwareRow}
/>
</div>
))
) : (
// Expanded view: Group by component BOMs
expandedHardwareByComponent.map(({ component, parts }) => (
<div key={component}>
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300 mb-3">{component}</h4>
<DataTable
columns={expandedColumns}
data={parts}
renderRow={renderExpandedHardwareRow}
/>
</div>
))
)}
</div>
</div>
);
}
HardwareTab.propTypes = {
hardwareParts: PropTypes.array.isRequired,
expandedHardwareByComponent: PropTypes.array.isRequired,
};

View File

@@ -0,0 +1,254 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import ImageWithFallback from '../ui/ImageWithFallback';
import FilamentDisplay from '../ui/FilamentDisplay';
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
import { useCurrency } from '../../contexts/CurrencyContext';
import { formatPriceWithConversion } from '../../utils/priceFormat';
import partsData from '../../data/index.js';
/**
* Overview tab component for BOM Summary
*/
export default function OverviewTab({
config,
filamentTotals,
totalTime,
total,
getColorName,
getColorHex
}) {
const { currency, exchangeRates } = useCurrency();
const [motorPrice, setMotorPrice] = useState('');
const [psuPrice, setPsuPrice] = useState('');
const [totalPrice, setTotalPrice] = useState('');
useEffect(() => {
const updatePrices = async () => {
if (config.motor) {
const price = await getPriceDisplayFromLinksAsync(config.motor, currency, exchangeRates);
setMotorPrice(price);
} else {
setMotorPrice('');
}
if (config.powerSupply) {
const price = await getPriceDisplayFromLinksAsync(config.powerSupply, currency, exchangeRates);
setPsuPrice(price);
} else {
setPsuPrice('');
}
if (total !== undefined && total !== null) {
const formatted = await formatPriceWithConversion(total, currency, exchangeRates);
setTotalPrice(formatted);
}
};
updatePrices();
}, [config.motor, config.powerSupply, total, currency, exchangeRates]);
return (
<div className="space-y-6">
{/* Hardware (Motor & Power Supply) */}
{(config.motor || config.powerSupply) && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Hardware</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{config.motor && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.motor.image}
alt={config.motor.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.motor.name}
</span>
{motorPrice && (
<span className="text-xs text-center text-gray-600 dark:text-gray-400 mt-1">
{motorPrice}
</span>
)}
</div>
)}
{config.powerSupply && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.powerSupply.image}
alt={config.powerSupply.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.powerSupply.name}
</span>
{psuPrice && (
<span className="text-xs text-center text-gray-600 dark:text-gray-400 mt-1">
{psuPrice}
</span>
)}
</div>
)}
</div>
</div>
)}
{/* Filament Usage */}
<FilamentDisplay
filamentTotals={filamentTotals}
totalTime={totalTime}
primaryColor={config.primaryColor}
accentColor={config.accentColor}
getColorName={getColorName}
getColorHex={getColorHex}
/>
{/* Selected Options/Kit */}
{(config.mount || config.cover || config.pcbMount || config.standHinge || config.standFeet ||
(config.standCrossbarSupports && config.standCrossbarSupports.length > 0) ||
(config.remoteType || config.remote?.id)) && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Selected Options</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{config.mount && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.mount.image}
alt={config.mount.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.mount.name}
</span>
</div>
)}
{config.cover && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.cover.image}
alt={config.cover.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.cover.name}
</span>
</div>
)}
{config.pcbMount && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.pcbMount.image}
alt={config.pcbMount.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.pcbMount.name}
</span>
</div>
)}
{config.standHinge && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.standHinge.image}
alt={config.standHinge.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.standHinge.name}
</span>
</div>
)}
{config.standFeet && (
<div className="flex flex-col items-center">
<ImageWithFallback
src={config.standFeet.image}
alt={config.standFeet.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{config.standFeet.name}
</span>
</div>
)}
{config.standCrossbarSupports && config.standCrossbarSupports.length > 0 && (
<>
{config.standCrossbarSupports.map((support) => (
<div key={support.id} className="flex flex-col items-center">
<ImageWithFallback
src={support.image}
alt={support.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{support.name}
</span>
</div>
))}
</>
)}
{(config.remoteType || config.remote?.id) && (() => {
const remoteId = config.remoteType || config.remote?.id;
const remoteSystem = partsData.components?.remotes?.systems?.[remoteId];
return remoteSystem ? (
<div className="flex flex-col items-center">
<ImageWithFallback
src={remoteSystem.image}
alt={remoteSystem.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{remoteSystem.name}
</span>
</div>
) : null;
})()}
</div>
</div>
)}
{/* Toy Mounts */}
{config.toyMountOptions && config.toyMountOptions.length > 0 && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Toy Mounts</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{config.toyMountOptions.map((toyMount) => (
<div key={toyMount.id} className="flex flex-col items-center">
<ImageWithFallback
src={toyMount.image}
alt={toyMount.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
/>
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{toyMount.name}
</span>
{toyMount.description && (
<span className="text-xs text-center text-gray-500 dark:text-gray-400 mt-1">
{toyMount.description}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Total */}
<div className="pt-4 border-t-2 border-gray-300 dark:border-gray-700">
<div className="flex justify-between items-center">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Total Hardware Cost</h3>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{totalPrice || '...'}
</p>
</div>
</div>
</div>
);
}
OverviewTab.propTypes = {
config: PropTypes.object.isRequired,
filamentTotals: PropTypes.object.isRequired,
totalTime: PropTypes.string.isRequired,
total: PropTypes.number.isRequired,
getColorName: PropTypes.func,
getColorHex: PropTypes.func,
};

View File

@@ -0,0 +1,222 @@
import PropTypes from 'prop-types';
import DataTable from '../ui/DataTable';
import FilamentDisplay from '../ui/FilamentDisplay';
import { getColorName, getColorHex } from '../../utils/partUtils';
/**
* Printed parts table row renderer
*/
const renderPrintedPartRow = (part, config, getColorNameFunc, getColorHexFunc) => {
const partColour = part.colour || 'primary';
const colorHex = getColorHexFunc(
partColour === 'primary' ? config.primaryColor : config.accentColor,
partColour
);
const colorName = getColorNameFunc(
partColour === 'primary' ? config.primaryColor : config.accentColor,
partColour
);
const formatFilamentEstimate = (estimate, quantity) => {
if (!estimate || estimate === 0) return null;
const total = typeof estimate === 'number'
? (estimate * quantity).toFixed(1)
: estimate;
const perUnit = typeof estimate === 'number'
? estimate.toFixed(1)
: parseFloat(estimate.replace(/[~g]/g, '').trim()) || 0;
return {
total: `${total}g`,
perUnit: quantity > 1 ? `(${perUnit.toFixed(1)}g × ${quantity})` : null
};
};
const filamentData = formatFilamentEstimate(part.filamentEstimate, part.quantity || 1);
return (
<tr key={part.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3 whitespace-nowrap">
<p className="text-sm font-medium text-gray-900 dark:text-white">{part.name}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: colorHex }}
title={`${partColour === 'primary' ? 'Primary' : 'Secondary'} color: ${colorName}`}
/>
<span className="text-xs text-gray-600 dark:text-gray-400 capitalize">{partColour}</span>
</div>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600 dark:text-gray-300">{part.description || '-'}</p>
</td>
<td className="px-4 py-3">
{part.isHardwareOnly ? (
<span className="text-xs text-blue-600 dark:text-blue-400 italic">Hardware only</span>
) : part.filePath ? (
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{part.filePath}</p>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{part.quantity || 1}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
{part.isHardwareOnly ? (
<span className="text-xs text-blue-600 dark:text-blue-400">-</span>
) : filamentData ? (
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{filamentData.total}
{filamentData.perUnit && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
{filamentData.perUnit}
</span>
)}
</p>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
)}
</td>
</tr>
);
};
/**
* Printed Parts tab component for BOM Summary
*/
export default function PrintedPartsTab({
printedParts,
config,
filamentTotals,
totalTime
}) {
// Group parts by category
const partsByCategory = printedParts.reduce((acc, part) => {
if (!acc[part.category]) {
acc[part.category] = [];
}
acc[part.category].push(part);
return acc;
}, {});
// Define main sections and their subcategories
const mainSections = {
'Actuator + Mount': ['Actuator Body', 'Mount', 'Cover', 'PCB Mount'],
'Stand': ['Stand', 'Stand Hinges', 'Stand Feet', 'Stand Crossbar Supports'],
'Remote': ['Remote Body', 'Remote Knobs'],
};
// Helper to check if a section has any parts
const sectionHasParts = (subcategories) => {
return subcategories.some(cat => partsByCategory[cat] && partsByCategory[cat].length > 0);
};
const printedPartsColumns = [
{ key: 'name', label: 'Part Name', align: 'left' },
{ key: 'color', label: 'Color', align: 'left' },
{ key: 'description', label: 'Description', align: 'left' },
{ key: 'filePath', label: 'File Path', align: 'left' },
{ key: 'quantity', label: 'Quantity', align: 'right' },
{ key: 'filament', label: 'Filament', align: 'right' },
];
if (printedParts.length === 0) {
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<p>No printed parts required for this configuration.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Required Printed Parts</h3>
</div>
<div className="space-y-6">
{Object.entries(mainSections).map(([mainSectionName, subcategories]) => {
if (!sectionHasParts(subcategories)) {
return null;
}
return (
<div key={mainSectionName} className="border-l-2 border-blue-200 dark:border-blue-700 pl-4">
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">{mainSectionName}</h4>
<div className="space-y-4 ml-2">
{subcategories.map((category) => {
const parts = partsByCategory[category];
if (!parts || parts.length === 0) {
return null;
}
return (
<div key={category}>
<div className="flex items-center gap-2 mb-3">
<h5 className="text-md font-medium text-gray-700 dark:text-gray-300">{category}</h5>
{parts.some(p => p.replacesActuatorMiddle) && (
<span className="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-1 rounded">
Replaces standard ossm-actuator-body-middle
</span>
)}
</div>
<DataTable
columns={printedPartsColumns}
data={parts}
renderRow={(part) => renderPrintedPartRow(part, config, getColorName, getColorHex)}
/>
</div>
);
})}
</div>
</div>
);
})}
{/* Other categories not in main sections (e.g., Toy Mounts) */}
{Object.entries(partsByCategory).map(([category, parts]) => {
const isInMainSection = Object.values(mainSections).flat().includes(category);
if (isInMainSection) {
return null;
}
return (
<div key={category}>
<div className="flex items-center gap-2 mb-3">
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300">{category}</h4>
{parts.some(p => p.replacesActuatorMiddle) && (
<span className="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-1 rounded">
Replaces standard ossm-actuator-body-middle
</span>
)}
</div>
<DataTable
columns={printedPartsColumns}
data={parts}
renderRow={(part) => renderPrintedPartRow(part, config, getColorName, getColorHex)}
/>
</div>
);
})}
<FilamentDisplay
filamentTotals={filamentTotals}
totalTime={totalTime}
primaryColor={config.primaryColor}
accentColor={config.accentColor}
getColorName={getColorName}
getColorHex={getColorHex}
/>
</div>
</div>
);
}
PrintedPartsTab.propTypes = {
printedParts: PropTypes.array.isRequired,
config: PropTypes.object.isRequired,
filamentTotals: PropTypes.object.isRequired,
totalTime: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,36 @@
import PropTypes from 'prop-types';
import { createShareLink } from '../../utils/shareService';
/**
* Share button component for creating shareable links
*/
export default function ShareButton({ config }) {
const handleShare = () => {
try {
const shareUrl = createShareLink(config);
// Copy to clipboard
navigator.clipboard.writeText(shareUrl).then(() => {
alert(`Share link copied to clipboard!\n\n${shareUrl}\n\nThis link will expire in 7 days.`);
}).catch(() => {
// Fallback: show the URL in a prompt
prompt('Share link (valid for 7 days):', shareUrl);
});
} catch (error) {
console.error('Error creating share link:', error);
alert('Error creating share link. Please try again.');
}
};
return (
<button
onClick={handleShare}
className="w-full px-6 py-3 bg-purple-600 dark:bg-purple-500 text-white rounded-lg font-medium hover:bg-purple-700 dark:hover:bg-purple-600 transition-colors"
>
Share Link (7 days)
</button>
);
}
ShareButton.propTypes = {
config: PropTypes.object.isRequired,
};

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { useCurrency } from '../contexts/CurrencyContext';
const currencies = [
{ code: 'USD', symbol: '$', name: 'US Dollar' },
{ code: 'CAD', symbol: 'C$', name: 'Canadian Dollar' },
{ code: 'EUR', symbol: '€', name: 'Euro' },
{ code: 'GBP', symbol: '£', name: 'British Pound' },
{ code: 'AUD', symbol: 'A$', name: 'Australian Dollar' },
{ code: 'JPY', symbol: '¥', name: 'Japanese Yen' },
{ code: 'CNY', symbol: '¥', name: 'Chinese Yuan' },
];
export default function CurrencySwitcher() {
const { currency, setCurrency } = useCurrency();
const [isOpen, setIsOpen] = useState(false);
const currentCurrency = currencies.find(c => c.code === currency) || currencies[0];
const handleCurrencyChange = (newCurrency) => {
setCurrency(newCurrency);
setIsOpen(false);
};
return (
<div className="fixed top-4 right-20 sm:right-24 z-50">
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 sm:p-3 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center gap-1 sm:gap-2 min-w-[70px] sm:min-w-[80px] justify-center"
aria-label="Change currency"
aria-expanded={isOpen}
>
<span className="text-xs sm:text-sm font-semibold text-gray-900 dark:text-white whitespace-nowrap">
{currentCurrency.symbol} {currentCurrency.code}
</span>
<svg
className={`w-4 h-4 text-gray-600 dark:text-gray-400 transition-transform flex-shrink-0 ${isOpen ? '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>
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<div className="py-1">
{currencies.map((curr) => (
<button
key={curr.code}
onClick={() => handleCurrencyChange(curr.code)}
className={`w-full text-left px-4 py-2 text-sm transition-colors ${
currency === curr.code
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-semibold'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-between">
<div>
<span className="font-medium">{curr.symbol}</span>
<span className="ml-2">{curr.code}</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{curr.name}
</span>
</div>
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ export default function ThemeToggle() {
return (
<button
onClick={toggleTheme}
className="fixed top-4 right-4 z-50 p-3 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110"
className="fixed top-4 right-4 z-50 p-3 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
aria-label="Toggle theme"
>
{theme === 'dark' ? (

View File

@@ -30,15 +30,15 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
const [currentStep, setCurrentStep] = useState(getInitialStep());
const [config, setConfig] = useState(initialConfig || {
motor: '57AIM30',
powerSupply: '24V PSU',
motor: null,
powerSupply: null,
primaryColor: 'black',
accentColor: 'black',
mount: 'Middle Pivot',
cover: 'Simple',
standHinge: 'Pivot Plate',
standFeet: '3030 Extrusion',
standCrossbarSupports: 'standard',
mount: null,
cover: null,
standHinge: null,
standFeet: null,
standCrossbarSupports: [],
pcbMount: null,
});

View File

@@ -1,8 +1,15 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
import { useCurrency } from '../../contexts/CurrencyContext';
import ImageWithFallback from '../ui/ImageWithFallback';
import AsyncPrice from '../ui/AsyncPrice';
export default function MotorStep({ config, updateConfig }) {
const selectedMotorId = config.motor?.id;
const { currency, exchangeRates } = useCurrency();
const [motorPrices, setMotorPrices] = useState({});
const handleSelect = (motor) => {
updateConfig({ motor });
@@ -12,37 +19,46 @@ export default function MotorStep({ config, updateConfig }) {
const otherMotors = partsData.motors.filter(m => !m.recommended);
const hasSingleRecommended = recommendedMotors.length === 1;
const renderMotorCard = (motor, isRecommended = false, isSlightlyLarger = false) => (
useEffect(() => {
const updatePrices = async () => {
if (!exchangeRates) return; // Wait for rates to load
const prices = {};
// Update prices for all motors
for (const m of partsData.motors) {
if (m.links && m.links.length > 0) {
const price = await getPriceDisplayFromLinksAsync(m, currency, exchangeRates);
prices[m.id] = price;
}
}
setMotorPrices(prices);
};
updatePrices();
}, [currency, exchangeRates]);
const renderMotorCard = (motor, 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
className={`w-full ${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${selectedMotorId === motor.id
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg'
: motor.recommended
? 'border-green-500 dark:border-green-600 bg-green-50 dark:bg-green-900/30 hover:border-green-600 dark:hover:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
? 'border-green-500 dark:border-green-600 bg-green-50 dark:bg-green-900/30 hover:border-green-600 dark:hover:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
{motor.recommended && (
<div className="mb-3 flex items-center gap-2">
<div className="mb-3 flex items-center justify-center gap-2">
<span className="inline-flex items-center px-3 py-1 text-xs font-semibold text-green-800 dark:text-green-300 bg-green-200 dark:bg-green-900/50 rounded-full">
Recommended
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 dark:bg-gray-700`}
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<ImageWithFallback
src={motor.image}
alt={motor.name}
className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100 dark:bg-gray-700`}
containerClassName={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}
/>
<div className="flex items-start justify-between mb-2">
<h3 className={`${isSlightlyLarger ? 'text-lg' : 'text-base'} font-semibold text-gray-900 dark:text-white`}>
{motor.name}
@@ -66,7 +82,7 @@ export default function MotorStep({ config, updateConfig }) {
)}
</div>
<p className={`${isSlightlyLarger ? 'text-sm' : 'text-sm'} text-gray-600 dark:text-gray-300 mb-3`}>{motor.description}</p>
<div className={`flex ${isSlightlyLarger ? 'gap-4' : 'gap-3'} text-sm`}>
<div className={`flex ${isSlightlyLarger ? 'gap-4' : 'gap-3'} text-sm mb-3`}>
<div>
<span className="text-gray-500 dark:text-gray-400">Speed:</span>{' '}
<span className="font-medium text-gray-900 dark:text-white">{motor.speed}</span>
@@ -80,42 +96,69 @@ export default function MotorStep({ config, updateConfig }) {
<span className="font-medium text-gray-900 dark:text-white">{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 dark:text-blue-400`}>
{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 dark:border-gray-700`}>
<p className="text-xs text-gray-500 dark:text-gray-400 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 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:text-blue-800 dark:hover:text-blue-200 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 className={`${isSlightlyLarger ? 'mb-3' : 'mb-2'} flex items-center justify-between`}>
<div className={`${isSlightlyLarger ? 'text-lg' : 'text-lg'} font-bold text-blue-600 dark:text-blue-400`}>
{motorPrices[motor.id] || '...'}
</div>
</div>
</div>
<div className={`${isSlightlyLarger ? 'mt-4' : 'mt-3'} pt-3 border-t border-gray-200 dark:border-gray-700`}>
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Buy from:</p>
<div className="space-y-2">
{motor.links.map((link, index) => (
<a
key={index}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="block p-2.5 rounded-md border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400">
{link.store}
</span>
<svg
className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
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>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{link.link}
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{link.price != null && (
<AsyncPrice
price={link.price}
className="text-sm font-bold text-blue-600 dark:text-blue-400 whitespace-nowrap"
fallback="..."
/>
)}
{link.updated && (
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
Updated {new Date(link.updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</span>
)}
</div>
</div>
</a>
))}
</div>
</div>
</>
)}
</button>
);
@@ -129,16 +172,16 @@ export default function MotorStep({ config, updateConfig }) {
{/* Recommended Motor(s) */}
{recommendedMotors.length > 0 && (
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center' : ''}`}>
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center w-full' : ''}`}>
{hasSingleRecommended ? (
<div className="w-full max-w-md">
{renderMotorCard(recommendedMotors[0], true, true)}
{renderMotorCard(recommendedMotors[0], true)}
</div>
) : (
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">Recommended Options</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recommendedMotors.map((motor) => renderMotorCard(motor, true, false))}
{recommendedMotors.map((motor) => renderMotorCard(motor, false))}
</div>
</div>
)}
@@ -150,10 +193,19 @@ export default function MotorStep({ config, updateConfig }) {
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">Other Options</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{otherMotors.map((motor) => renderMotorCard(motor, false, false))}
{otherMotors.map((motor) => renderMotorCard(motor, false))}
</div>
</div>
)}
</div>
);
}
MotorStep.propTypes = {
config: PropTypes.shape({
motor: PropTypes.shape({
id: PropTypes.string,
}),
}).isRequired,
updateConfig: PropTypes.func.isRequired,
};

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
import ImageWithFallback from '../ui/ImageWithFallback';
export default function OptionsStep({ config, updateConfig, buildType }) {
const [expandedMainSections, setExpandedMainSections] = useState({});
@@ -172,18 +173,12 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/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 dark:bg-gray-700"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<ImageWithFallback
src={option.image}
alt={option.name}
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
containerClassName="mb-3 flex justify-center"
/>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">

View File

@@ -1,9 +1,15 @@
import { useState, useEffect } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
import { useCurrency } from '../../contexts/CurrencyContext';
import ImageWithFallback from '../ui/ImageWithFallback';
import AsyncPrice from '../ui/AsyncPrice';
export default function PowerSupplyStep({ config, updateConfig }) {
const selectedPowerSupplyId = config.powerSupply?.id;
const selectedMotorId = config.motor?.id;
const { currency, exchangeRates } = useCurrency();
const [psuPrices, setPsuPrices] = useState({});
const handleSelect = (powerSupply) => {
updateConfig({ powerSupply });
@@ -15,6 +21,21 @@ export default function PowerSupplyStep({ config, updateConfig }) {
return psu.compatibleMotors.includes(selectedMotorId);
});
useEffect(() => {
const updatePrices = async () => {
if (!exchangeRates) return;
const prices = {};
for (const psu of compatiblePowerSupplies) {
if (psu.links && psu.links.length > 0) {
const price = await getPriceDisplayFromLinksAsync(psu, currency, exchangeRates);
prices[psu.id] = price;
}
}
setPsuPrices(prices);
};
updatePrices();
}, [currency, exchangeRates, compatiblePowerSupplies]);
return (
<div>
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Select Power Supply</h2>
@@ -38,24 +59,17 @@ export default function PowerSupplyStep({ config, updateConfig }) {
<button
key={powerSupply.id}
onClick={() => handleSelect(powerSupply)}
className={`p-6 border-2 rounded-lg text-left transition-all ${
selectedPowerSupplyId === powerSupply.id
className={`w-full p-6 border-2 rounded-lg text-left transition-all ${selectedPowerSupplyId === powerSupply.id
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/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 dark:bg-gray-700"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<ImageWithFallback
src={powerSupply.image}
alt={powerSupply.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
containerClassName="mb-4 flex justify-center"
/>
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{powerSupply.name}
@@ -81,7 +95,7 @@ export default function PowerSupplyStep({ config, updateConfig }) {
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
{powerSupply.description}
</p>
<div className="flex gap-4 text-sm">
<div className="flex gap-4 text-sm mb-3">
<div>
<span className="text-gray-500 dark:text-gray-400">Voltage:</span>{' '}
<span className="font-medium text-gray-900 dark:text-white">{powerSupply.voltage}</span>
@@ -91,42 +105,69 @@ export default function PowerSupplyStep({ config, updateConfig }) {
<span className="font-medium text-gray-900 dark:text-white">{powerSupply.current}</span>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
{formatPrice(powerSupply.price)}
</div>
</div>
{powerSupply.links && powerSupply.links.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 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 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:text-blue-800 dark:hover:text-blue-200 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 className="mb-3 flex items-center justify-between">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
{psuPrices[powerSupply.id] || '...'}
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Buy from:</p>
<div className="space-y-2">
{powerSupply.links.map((link, index) => (
<a
key={index}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="block p-2.5 rounded-md border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400">
{link.store}
</span>
<svg
className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
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>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{link.link}
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{link.price != null && (
<AsyncPrice
price={link.price}
className="text-sm font-bold text-blue-600 dark:text-blue-400 whitespace-nowrap"
fallback="..."
/>
)}
{link.updated && (
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
Updated {new Date(link.updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</span>
)}
</div>
</div>
</a>
))}
</div>
</div>
</>
)}
</button>
))}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
import ImageWithFallback from '../ui/ImageWithFallback';
export default function RemoteStep({ config, updateConfig, buildType }) {
const [expandedKnobs, setExpandedKnobs] = useState(false);
@@ -93,18 +94,12 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/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 dark:bg-gray-700"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<ImageWithFallback
src={imagePath}
alt={remote.name}
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
containerClassName="mb-3 flex justify-center"
/>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat';
import ImageWithFallback from '../ui/ImageWithFallback';
export default function ToyMountStep({ config, updateConfig }) {
const [expandedSubSections, setExpandedSubSections] = useState({});
@@ -56,18 +57,13 @@ export default function ToyMountStep({ config, updateConfig }) {
}`}
>
<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 dark:bg-gray-700"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
<div className="flex-shrink-0">
<ImageWithFallback
src={option.image}
alt={option.name}
className="h-24 w-24 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useCurrency } from '../../contexts/CurrencyContext';
import { formatPriceWithConversion } from '../../utils/priceFormat';
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
/**
* Component that displays a price with automatic currency conversion (async)
* Handles both price objects and item objects with links (motor, PSU, PCB)
*/
export default function AsyncPrice({ price, className = '', fallback = '...' }) {
const { currency, exchangeRates } = useCurrency();
const [formattedPrice, setFormattedPrice] = useState(fallback);
useEffect(() => {
if (!price && price !== 0) {
setFormattedPrice('C$0.00');
return;
}
const updatePrice = async () => {
try {
// Check if this is an item object with links (like motor, PSU, PCB)
if (typeof price === 'object' && price.links && Array.isArray(price.links) && price.links.length > 0) {
// Use getPriceDisplayFromLinksAsync for items with links
const formatted = await getPriceDisplayFromLinksAsync(price, currency, exchangeRates);
setFormattedPrice(formatted);
} else {
// Use formatPriceWithConversion for price objects/numbers/strings
const formatted = await formatPriceWithConversion(price, currency, exchangeRates);
setFormattedPrice(formatted);
}
} catch (error) {
console.warn('Failed to format price:', error);
// Fallback to basic formatting
if (typeof price === 'number') {
setFormattedPrice(`C$${price.toFixed(2)}`);
} else if (typeof price === 'object' && price.amount) {
const amount = typeof price.amount === 'object' ? price.amount.min : price.amount;
setFormattedPrice(`C$${amount?.toFixed(2) || '0.00'}`);
} else if (typeof price === 'string') {
setFormattedPrice(price);
} else {
setFormattedPrice('C$0.00');
}
}
};
updatePrice();
}, [price, currency, exchangeRates]);
return <span className={className}>{formattedPrice}</span>;
}
AsyncPrice.propTypes = {
price: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
PropTypes.object,
]),
className: PropTypes.string,
fallback: PropTypes.string,
};

View File

@@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
/**
* Reusable data table component
*/
export default function DataTable({
columns,
data,
renderRow,
className = '',
emptyMessage = 'No data available'
}) {
if (!data || data.length === 0) {
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<p>{emptyMessage}</p>
</div>
);
}
return (
<div className={`overflow-x-auto ${className}`}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{columns.map((column) => (
<th
key={column.key}
className={`px-4 py-3 text-${column.align || 'left'} text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider`}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((row, index) => renderRow(row, index))}
</tbody>
</table>
</div>
);
}
DataTable.propTypes = {
columns: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
align: PropTypes.oneOf(['left', 'right', 'center']),
})
).isRequired,
data: PropTypes.array.isRequired,
renderRow: PropTypes.func.isRequired,
className: PropTypes.string,
emptyMessage: PropTypes.string,
};

View File

@@ -0,0 +1,204 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import JSZip from 'jszip';
import { createShareLink } from '../../utils/shareService';
import { generateMarkdownOverview, generateExcelBOM, generateExcelPrintList } from '../../utils/exportUtils';
/**
* Export button component with progress indicator
*/
export default function ExportButton({
config,
printedParts,
hardwareParts,
filamentTotals,
totalTime,
total
}) {
const [isExportingZip, setIsExportingZip] = useState(false);
const [zipProgress, setZipProgress] = useState({ current: 0, total: 0, currentFile: '' });
const handleExport = async () => {
try {
setIsExportingZip(true);
setZipProgress({ current: 0, total: 0, currentFile: 'Preparing export...' });
const zip = new JSZip();
// 1. Generate and add markdown overview
setZipProgress({ current: 1, total: 100, currentFile: 'Generating overview...' });
const markdownOverview = generateMarkdownOverview(
config,
printedParts,
hardwareParts,
filamentTotals,
totalTime,
total
);
zip.file('README.md', markdownOverview);
// 2. Generate and add Excel BOM
setZipProgress({ current: 20, total: 100, currentFile: 'Generating BOM...' });
const bomWorkbook = generateExcelBOM(hardwareParts, printedParts, config);
const bomBuffer = await bomWorkbook.xlsx.writeBuffer();
zip.file('BOM.xlsx', bomBuffer);
// 3. Generate and add Excel Print List
setZipProgress({ current: 40, total: 100, currentFile: 'Generating print list...' });
const printListWorkbook = generateExcelPrintList(printedParts, filamentTotals);
const printListBuffer = await printListWorkbook.xlsx.writeBuffer();
zip.file('Print_List.xlsx', printListBuffer);
// 4. Download and organize print files by component and colors
setZipProgress({ current: 50, total: 100, currentFile: 'Organizing print files...' });
const partsToDownload = printedParts.filter(part => part.url && !part.isHardwareOnly);
if (partsToDownload.length > 0) {
// Convert GitHub blob URLs to raw.githubusercontent.com URLs
const convertGitHubUrl = (url) => {
if (!url) return url;
const blobMatch = url.match(/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/\?]+)\/(.+?)(\?raw=true)?$/);
if (blobMatch) {
const [, owner, repo, branch, encodedPath] = blobMatch;
const decodedPath = decodeURIComponent(encodedPath);
const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/`;
const urlObj = new URL(decodedPath, baseUrl);
return urlObj.href;
}
return url;
};
// Download files with concurrency limit
const downloadFile = async (part, index) => {
try {
const progress = 50 + Math.floor((index / partsToDownload.length) * 40);
setZipProgress({
current: progress,
total: 100,
currentFile: `Downloading ${part.filePath || part.name}...`
});
const rawUrl = convertGitHubUrl(part.url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const response = await fetch(rawUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Failed to download ${part.filePath}: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
// Organize by component/color: Print_Files/Component/Color/filename
const componentDir = part.category || 'Other';
const colourDir = part.colour === 'primary' ? 'Primary' : part.colour === 'secondary' ? 'Accent' : 'Other';
const filename = part.filePath || `${part.id}.stl`;
const zipPath = `Print_Files/${componentDir}/${colourDir}/${filename}`;
zip.file(zipPath, arrayBuffer);
return { success: true, part: part.filePath };
} catch (error) {
if (error.name === 'AbortError') {
console.error(`Timeout downloading ${part.filePath}`);
} else {
console.error(`Error downloading ${part.filePath}:`, error);
}
return { success: false, part: part.filePath, error: error.message };
}
};
// Download files with concurrency limit (3 at a time)
const concurrencyLimit = 3;
const results = [];
for (let i = 0; i < partsToDownload.length; i += concurrencyLimit) {
const batch = partsToDownload.slice(i, i + concurrencyLimit);
const batchPromises = batch.map((part, batchIndex) => downloadFile(part, i + batchIndex));
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
}
const successful = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
console.warn(`Failed to download ${failed.length} file(s):`, failed.map(f => f.part));
}
}
// 5. Generate final zip
setZipProgress({ current: 95, total: 100, currentFile: 'Creating ZIP file...' });
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
// 6. Download
setZipProgress({ current: 100, total: 100, currentFile: 'Complete!' });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'ossm-build-export.zip';
a.style.display = 'none';
document.body.appendChild(a);
await new Promise(resolve => setTimeout(resolve, 100));
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 200);
setZipProgress({ current: 0, total: 0, currentFile: '' });
setIsExportingZip(false);
} catch (error) {
console.error('Error creating export:', error);
alert('Error creating export. Please try again.');
setZipProgress({ current: 0, total: 0, currentFile: '' });
setIsExportingZip(false);
}
};
return (
<button
onClick={handleExport}
disabled={isExportingZip}
className="w-full px-6 py-3 bg-green-600 dark:bg-green-500 text-white rounded-lg font-medium hover:bg-green-700 dark:hover:bg-green-600 transition-colors disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
>
{isExportingZip ? (
<div className="flex flex-col items-center">
<span>Exporting...</span>
{zipProgress.total > 0 && (
<div className="mt-2 w-full">
<div className="flex justify-between text-xs mb-1">
<span>{zipProgress.current}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${zipProgress.current}%` }}
/>
</div>
{zipProgress.currentFile && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">{zipProgress.currentFile}</p>
)}
</div>
)}
</div>
) : (
'Export All'
)}
</button>
);
}
ExportButton.propTypes = {
config: PropTypes.object.isRequired,
printedParts: PropTypes.array.isRequired,
hardwareParts: PropTypes.array.isRequired,
filamentTotals: PropTypes.object.isRequired,
totalTime: PropTypes.string.isRequired,
total: PropTypes.number.isRequired,
};

View File

@@ -0,0 +1,76 @@
import PropTypes from 'prop-types';
/**
* Component for displaying filament usage information
*/
export default function FilamentDisplay({
filamentTotals,
totalTime,
primaryColor,
accentColor,
getColorName,
getColorHex
}) {
if (filamentTotals.total === 0 && totalTime === '0m') {
return null;
}
return (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">Filament Usage</h3>
<div className="space-y-2">
{filamentTotals.total > 0 && (
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-700 dark:text-gray-300">Total Filament:</span>
<span className="font-bold text-gray-900 dark:text-white">{Math.round(filamentTotals.total)}g</span>
</div>
{filamentTotals.primary > 0 && getColorName && getColorHex && (
<div className="flex justify-between items-center text-sm text-gray-600 dark:text-gray-400 ml-4">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: getColorHex(primaryColor, 'primary') }}
/>
<span>Primary ({getColorName(primaryColor, 'primary')}):</span>
</div>
<span>{Math.round(filamentTotals.primary)}g</span>
</div>
)}
{filamentTotals.secondary > 0 && getColorName && getColorHex && (
<div className="flex justify-between items-center text-sm text-gray-600 dark:text-gray-400 ml-4">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: getColorHex(accentColor, 'accent') }}
/>
<span>Secondary ({getColorName(accentColor, 'accent')}):</span>
</div>
<span>{Math.round(filamentTotals.secondary)}g</span>
</div>
)}
</div>
)}
{totalTime !== '0m' && (
<div className="flex justify-between items-center pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="font-semibold text-gray-700 dark:text-gray-300">Total Printing Time:</span>
<span className="font-bold text-gray-900 dark:text-white">{totalTime}</span>
</div>
)}
</div>
</div>
);
}
FilamentDisplay.propTypes = {
filamentTotals: PropTypes.shape({
primary: PropTypes.number,
secondary: PropTypes.number,
total: PropTypes.number,
}).isRequired,
totalTime: PropTypes.string.isRequired,
primaryColor: PropTypes.string,
accentColor: PropTypes.string,
getColorName: PropTypes.func,
getColorHex: PropTypes.func,
};

View File

@@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
/**
* Image component with error handling fallback
*/
export default function ImageWithFallback({
src,
alt,
className = '',
containerClassName = '',
onError
}) {
const handleError = (e) => {
e.target.style.display = 'none';
if (onError) {
onError(e);
}
};
if (!src) return null;
return (
<div className={containerClassName}>
<img
src={src}
alt={alt}
className={className}
onError={handleError}
/>
</div>
);
}
ImageWithFallback.propTypes = {
src: PropTypes.string,
alt: PropTypes.string.isRequired,
className: PropTypes.string,
containerClassName: PropTypes.string,
onError: PropTypes.func,
};

View File

@@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import ImageWithFallback from './ImageWithFallback';
/**
* Reusable option card component for displaying selectable options
*/
export default function OptionCard({
option,
isSelected = false,
isMultiSelect = false,
onClick,
showPrice = false,
imageSize = 'h-32 w-32',
className = '',
}) {
return (
<button
onClick={onClick}
className={`flex flex-col items-center p-4 border-2 rounded-lg transition-all ${isSelected
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
} ${className}`}
>
{option.image && (
<ImageWithFallback
src={option.image}
alt={option.name}
className={`${imageSize} object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2`}
/>
)}
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
{option.name}
</span>
{option.description && (
<span className="text-xs text-center text-gray-500 dark:text-gray-400 mt-1">
{option.description}
</span>
)}
{showPrice && option.price && (
<span className="text-xs text-center text-gray-600 dark:text-gray-400 mt-1">
{typeof option.price === 'string' ? option.price : `$${option.price}`}
</span>
)}
{isSelected && (
<div className="mt-2 w-6 h-6 bg-blue-600 dark:bg-blue-500 rounded-full flex items-center justify-center">
{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>
)}
</button>
);
}
OptionCard.propTypes = {
option: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
image: PropTypes.string,
description: PropTypes.string,
price: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired,
isSelected: PropTypes.bool,
isMultiSelect: PropTypes.bool,
onClick: PropTypes.func.isRequired,
showPrice: PropTypes.bool,
imageSize: PropTypes.string,
className: PropTypes.string,
};

View File

@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useCurrency } from '../../contexts/CurrencyContext';
import { formatPriceWithConversion } from '../../utils/priceFormat';
/**
* Component that displays a price with automatic currency conversion
*/
export default function PriceDisplay({ price, className = '' }) {
const { currency, exchangeRates } = useCurrency();
const [formattedPrice, setFormattedPrice] = useState('');
useEffect(() => {
if (!price) {
setFormattedPrice('$0.00');
return;
}
const updatePrice = async () => {
try {
const formatted = await formatPriceWithConversion(price, currency, exchangeRates);
setFormattedPrice(formatted);
} catch (error) {
console.warn('Failed to format price:', error);
// Fallback to basic formatting
setFormattedPrice(typeof price === 'number' ? `C$${price.toFixed(2)}` : String(price));
}
};
updatePrice();
}, [price, currency, exchangeRates]);
return <span className={className}>{formattedPrice}</span>;
}
PriceDisplay.propTypes = {
price: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
PropTypes.object,
]),
className: PropTypes.string,
};

View File

@@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
/**
* Reusable tab navigation component
*/
export default function TabNavigation({ tabs, activeTab, onTabChange, className = '' }) {
return (
<div className={`border-b border-gray-200 dark:border-gray-700 mb-6 ${className}`}>
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-blue-500 dark:border-blue-400 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}
TabNavigation.propTypes = {
tabs: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
activeTab: PropTypes.string.isRequired,
onTabChange: PropTypes.func.isRequired,
className: PropTypes.string,
};

View File

@@ -0,0 +1,7 @@
// Reusable UI Components
export { default as ImageWithFallback } from './ImageWithFallback';
export { default as OptionCard } from './OptionCard';
export { default as TabNavigation } from './TabNavigation';
export { default as DataTable } from './DataTable';
export { default as FilamentDisplay } from './FilamentDisplay';
export { default as ExportButton } from './ExportButton';

View File

@@ -0,0 +1,84 @@
import { createContext, useContext, useState, useEffect } from 'react';
const CurrencyContext = createContext();
export const useCurrency = () => {
const context = useContext(CurrencyContext);
if (!context) {
throw new Error('useCurrency must be used within a CurrencyProvider');
}
return context;
};
export const CurrencyProvider = ({ children }) => {
const [currency, setCurrency] = useState(() => {
// Check localStorage first
if (typeof window !== 'undefined') {
const savedCurrency = localStorage.getItem('currency');
if (savedCurrency) {
return savedCurrency;
}
// Try to detect currency from browser locale
const locale = navigator.language || navigator.userLanguage;
if (locale.includes('en-CA') || locale.includes('fr-CA')) {
return 'CAD';
}
if (locale.includes('en-GB')) {
return 'GBP';
}
if (locale.includes('en-AU')) {
return 'AUD';
}
if (locale.includes('eu') || locale.includes('de') || locale.includes('fr') || locale.includes('es') || locale.includes('it')) {
return 'EUR';
}
if (locale.includes('ja') || locale.includes('JP')) {
return 'JPY';
}
if (locale.includes('zh') || locale.includes('CN')) {
return 'CNY';
}
}
return 'CAD'; // Default to CAD
});
const [exchangeRates, setExchangeRates] = useState(null);
// Preload exchange rates on mount
useEffect(() => {
import('../utils/currencyService').then(({ getExchangeRates }) => {
getExchangeRates().then(rates => {
setExchangeRates(rates);
});
});
}, []);
// Update exchange rates when currency changes
useEffect(() => {
if (currency && typeof window !== 'undefined') {
import('../utils/currencyService').then(({ getExchangeRates }) => {
getExchangeRates().then(rates => {
setExchangeRates(rates);
});
});
}
}, [currency]);
useEffect(() => {
// Save to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('currency', currency);
}
}, [currency]);
const setCurrencyWithSave = (newCurrency) => {
setCurrency(newCurrency);
localStorage.setItem('currency', newCurrency);
};
return (
<CurrencyContext.Provider value={{ currency, setCurrency: setCurrencyWithSave, exchangeRates }}>
{children}
</CurrencyContext.Provider>
);
};

View File

@@ -4,115 +4,172 @@
"id": "hardware-fasteners-m3x8-shcs",
"name": "M3x8 SHCS",
"description": "Hardware fasteners m3x8 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M3x16 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x16-shcs",
"name": "M3x16 SHCS",
"description": "Hardware fasteners m3x16 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M3x20 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x20-shcs",
"name": "M3x20 SHCS",
"description": "m3x20 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M3 Hex Nut": {
"id": "hardware-fasteners-m3-hex-nut",
"name": "M3 Hex Nut",
"description": "Hardware fasteners m3 hex nut",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M4x10 Socket Head cap Screw": {
"id": "hardware-fasteners-m4x10-shcs",
"name": "M4x10 SHCS",
"description": "Hardware fasteners m4x10 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M4x12 Socket Head cap Screw": {
"id": "hardware-fasteners-m4x12-shcs",
"name": "M4x12 SHCS",
"description": "Hardware fasteners m4x12 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M4x25 Socket Head cap Screw": {
"id": "hardware-fasteners-m4x25-shcs",
"name": "M4x25 SHCS",
"description": "Hardware fasteners m4x25 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M4 Hex Nuts": {
"id": "hardware-fasteners-m4-hex-nuts",
"name": "M4 Hex Nuts",
"description": "Hardware fasteners m4 hex nuts",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M5 Hex Nuts": {
"id": "hardware-fasteners-m5-hex-nuts",
"name": "M5 Hex Nuts",
"description": "Hardware fasteners m5 hex nuts",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M5x20 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x20-shcs",
"name": "M5x20 SHCS",
"description": "Hardware fasteners m5x20 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M5x35 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x35-shcs",
"name": "M5x35 SHCS",
"description": "Hardware fasteners m5x35 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M5x40 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x40-shcs",
"name": "M5x40 SHCS",
"description": "Hardware fasteners m5x40 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"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
"price": {
"amount": 0,
"currency": "USD"
}
},
"M6x12 Socket Head cap Screw": {
"id": "hardware-fasteners-m6x12-shcs",
"name": "M6x12 SHCS",
"description": "Hardware fasteners m6x12 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M6x20mm Socket Head cap Screw": {
"id": "hardware-fasteners-m6x20mm-shcs",
"name": "M6x20mm SHCS",
"description": "Hardware fasteners m6x20mm socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M6x25 Socket Head cap Screw": {
"id": "hardware-fasteners-m6x25-shcs",
"name": "M6x25 SHCS",
"description": "Hardware fasteners m6x25 socket head cap screw",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M6 T Nuts": {
"id": "hardware-fasteners-m6-t-nuts",
"name": "M6 T Nuts",
"description": "Hardware fasteners m6 t nuts",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M6 Washer": {
"id": "hardware-fasteners-m6-washer",
"name": "M6 Washer",
"description": "Hardware fasteners m6 washer",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"M6x25 Handle": {
"id": "hardware-fasteners-m6x25-handle",
"name": "M6x25 Handle",
"description": "Hardware fasteners m6x25 handle",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
}
},
"motionComponents": {
@@ -120,25 +177,37 @@
"id": "hardware-gt2-pulley",
"name": "GT2 Pulley",
"description": "8mm Bore, 20T, 10mm Wide",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"GT2 Belt": {
"id": "hardware-gt2-belt",
"name": "GT2 Belt",
"description": "10mm wide, 500mm long",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"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
"price": {
"amount": 0,
"currency": "USD"
}
},
"Bearing MR115-2RS": {
"id": "hardware-bearing-MR115-2RS 5x11x4mm",
"name": "Bearing MR115-2RS 5x11x4mm",
"description": "MR115-2RS 5x11x4mm",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
}
},
"extrusions": {
@@ -146,7 +215,10 @@
"id": "hardware-fasteners-3030-90-degree-support",
"name": "3030 90 Degree Support",
"description": "Hardware fasteners 3030 90 degree support",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
}
},
"other": {
@@ -154,31 +226,46 @@
"id": "remote-hardware",
"name": "Remote Hardware",
"description": "Remote hardware",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"PitClamp Hardware": {
"id": "pitclamp-hardware",
"name": "PitClamp Hardware",
"description": "PitClamp hardware",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"PitClamp Reinforced 3030 Hardware": {
"id": "pitclamp-reinforced-3030-hardware",
"name": "PitClamp Reinforced 3030 Hardware",
"description": "Hardware for PitClamp Reinforced 3030 hinges",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"Middle Pivot Hardware": {
"id": "middle-pivot-hardware",
"name": "Middle Pivot Hardware",
"description": "Middle Pivot hardware",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
},
"Toy Mount Hardware": {
"id": "toy-mount-hardware",
"name": "Toy Mount Hardware",
"description": "Toy mount hardware",
"price": 0
"price": {
"amount": 0,
"currency": "USD"
}
}
}
}

View File

@@ -213,14 +213,6 @@
"ossm-actuator-body-middle-pivot"
]
},
{
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-24mm-nut-5-sided"
]
},
{
"id": "hardware-gt2-pulley",
"required": true,

View File

@@ -2,14 +2,36 @@
{
"id": "57AIM30",
"name": "57AIM30 \"Gold Motor\"",
"description": "Standard NEMA 17 stepper motor with 1.8° step angle",
"description": "This servo motor is specially designed for compact robotics applications with higher torque and lower speed than a traditional brushless servo.",
"speed": "1500 RPM",
"wattage": "100W",
"gear_count": "RS485",
"price": "$125-$250",
"image": "/images/motors/57AIM30.png",
"required": true,
"recommended": true
"recommended": true,
"links": [
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-motor-gold-motor",
"price": {
"amount": {
"min": 206.96,
"max": 234.00
},
"currency": "CAD"
},
"updated": "2026-01-10"
},
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/1005008561507369.html",
"price": {
"amount": 125.38,
"currency": "CAD"
},
"updated": "2026-01-10"
}
]
},
{
"id": "42AIM",
@@ -18,10 +40,20 @@
"speed": "1500 RPM",
"wattage": "100W",
"gear_count": "RS485",
"price": "$135-$270",
"image": "/images/motors/42AIM30.png",
"required": true,
"recommended": false
"recommended": false,
"links": [
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/1005009689441933.html",
"price": {
"amount": 142.38,
"currency": "CAD"
},
"updated": "2026-01-10"
}
]
},
{
"id": "iHSV57",
@@ -30,9 +62,19 @@
"speed": "3000 RPM",
"wattage": "180W",
"gear_count": "RS485",
"price": "$150-$300",
"image": "/images/motors/iHSV57.png",
"required": true,
"recommended": false
"recommended": false,
"links": [
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/1005009473450253.html",
"price": {
"amount": 179.38,
"currency": "CAD"
},
"updated": "2026-01-10"
}
]
}
]

View File

@@ -0,0 +1,21 @@
[
{
"id": "ossm-v2-pcb",
"name": "OSSM V2.3 PCB",
"description": "Printed circuit board for OSSM v2.3. Features ESP32 microcontroller, sensorless homing (no limit switches needed), enhanced motor stability with large capacitor, over-voltage protection, 4-pin JST PH header for motor connections, and power monitoring with voltage/current sensing. Supports both stepper and servo-based configurations with 24V power input via 2.1mm barrel jack.",
"image": "/images/pcb/ossm-v2-pcb.png",
"required": true,
"recommended": true,
"links": [
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-pcb-only",
"price": {
"amount": 83.20,
"currency": "CAD"
},
"updated": "2026-01-10"
}
]
}
]

View File

@@ -5,7 +5,6 @@
"description": "24V DC power supply, 5A output",
"voltage": "24V",
"current": "5A",
"price": 20,
"image": "/images/power-supplies/24v-PSU.png",
"compatibleMotors": [
"57AIM30",
@@ -16,25 +15,39 @@
"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"
"link": "https://a.co/d/6OZ6fwe",
"price": {
"amount": 25.96,
"currency": "CAD"
},
"updated": "2026-01-10"
},
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/100500312131213.html"
"link": "https://www.aliexpress.com/item/1005005620894702.html",
"price": {
"amount": 15.96,
"currency": "CAD"
},
"updated": "2026-01-10"
},
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-24v-power-supply"
"link": "https://www.researchanddesire.com/products/ossm-24v-power-supply",
"price": {
"amount": 46.80,
"currency": "CAD"
},
"updated": "2026-01-10"
}
]
},
{
"id": "psu-24v-usbc-pd",
"name": "24v USB-C PD Adapter",
"description": "24V USB-C PD Adapter, Requires 100W+ Power Supply",
"description": "USB-C to 5.5x2.5mm 100w 12v Cable, Requires 100W+ Power Supply",
"voltage": "24V",
"current": "5A",
"price": 30,
"image": "/images/power-supplies/24v-usbc-pd.png",
"compatibleMotors": [
"57AIM30",
@@ -45,15 +58,30 @@
"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"
"link": "https://a.co/d/hIq5mRj",
"price": {
"amount": 15.99,
"currency": "CAD"
},
"updated": "2026-01-10"
},
{
"store": "AliExpress",
"link": "https://www.aliexpress.com/item/100500312131213.html"
"link": "https://www.aliexpress.com/item/1005003202359212.html",
"price": {
"amount": 1.62,
"currency": "CAD"
},
"updated": "2026-01-10"
},
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-24v-usb-c-adapter"
"link": "https://www.researchanddesire.com/products/ossm-24v-usb-c-adapter",
"price": {
"amount": 18.72,
"currency": "CAD"
},
"updated": "2026-01-10"
}
]
}

View File

@@ -8,7 +8,10 @@
"description": "Pivot plate for the stand",
"image": "/images/options/pivot-plate.webp",
"hardwareCost": 10,
"price": 0,
"price": {
"amount": 0,
"currency": "USD"
},
"printedParts": [
{
"id": "pivot-plate",
@@ -99,7 +102,10 @@
"description": "Reinforced 3030 hinges for PitClamp",
"image": "/images/options/pitclamp-reinforced-3030-hinges.jpg",
"hardwareCost": 15,
"price": 0,
"price": {
"amount": 0,
"currency": "USD"
},
"printedParts": [
{
"id": "pitclamp-reinforced-3030",
@@ -131,7 +137,10 @@
"filamentEstimate": 50,
"image": "/images/options/standard-feet.jpg",
"hardwareCost": 0,
"price": 0,
"price": {
"amount": 0,
"currency": "USD"
},
"colour": "secondary",
"required": true
},
@@ -142,7 +151,10 @@
"filamentEstimate": 60,
"image": "/images/options/suction-feet.jpg",
"hardwareCost": 5,
"price": 0,
"price": {
"amount": 0,
"currency": "USD"
},
"colour": "secondary",
"required": true
}
@@ -204,7 +216,13 @@
"filamentEstimate": 0,
"image": "/images/options/standard-90-degree-support.jpg",
"hardwareCost": 10,
"price": "$10.00-$20.00",
"price": {
"amount": {
"min": 10.00,
"max": 20.00
},
"currency": "USD"
},
"colour": "primary",
"required": true,
"isHardwareOnly": true
@@ -216,7 +234,13 @@
"filamentEstimate": 100,
"image": "/images/options/3d-printed-90-degree-support.jpg",
"hardwareCost": 2,
"price": "$2.00-$4.00",
"price": {
"amount": {
"min": 2.00,
"max": 4.00
},
"currency": "USD"
},
"colour": "secondary",
"required": true
}

View File

@@ -1,5 +1,6 @@
import motors from './components/motors.json';
import powerSupplies from './components/powerSupplies.json';
import pcbs from './components/pcb.json';
import optionsData from './config/options.json';
import colors from './common/colors.json';
import hardwareData from './common/hardware.json';
@@ -245,6 +246,7 @@ const options = processOptions(optionsData, components);
export default {
motors,
powerSupplies,
pcbs,
options,
colors,
components,

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { useCurrency } from '../contexts/CurrencyContext';
import { formatPrice as formatPriceUtil } from '../utils/priceFormat';
import { convertPrice } from '../utils/currencyService';
/**
* Hook to format prices using the selected currency from context with conversion
*/
export function usePriceFormat() {
const { currency, exchangeRates } = useCurrency();
const [convertedPriceCache, setConvertedPriceCache] = useState(new Map());
const formatPrice = async (price, preferredCurrency = null) => {
const displayCurrency = preferredCurrency || currency;
// Convert price to target currency if needed
if (exchangeRates && price) {
try {
const converted = await convertPrice(price, displayCurrency, exchangeRates);
return formatPriceUtil(converted, displayCurrency);
} catch (error) {
console.warn('Failed to convert price, using original:', error);
return formatPriceUtil(price, displayCurrency);
}
}
return formatPriceUtil(price, displayCurrency);
};
// Synchronous version for use in render (uses cache or returns promise)
const formatPriceSync = (price, preferredCurrency = null) => {
const displayCurrency = preferredCurrency || currency;
// For now, return the formatted price without conversion in sync mode
// Conversion will happen in components that can handle async
return formatPriceUtil(price, displayCurrency);
};
return { formatPrice, formatPriceSync, currency, exchangeRates };
}

View File

@@ -3,11 +3,18 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { ThemeProvider } from './contexts/ThemeContext'
import { CurrencyProvider } from './contexts/CurrencyContext'
import { preloadExchangeRates } from './utils/currencyService'
// Preload exchange rates on app start
preloadExchangeRates();
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<App />
<CurrencyProvider>
<App />
</CurrencyProvider>
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,244 @@
import partsData from '../data/index.js';
import { getNumericPrice, extractNumericPrice, formatPrice, formatPriceWithConversion } from './priceFormat';
import { convertPrice } from './currencyService';
/**
* Evaluate a condition object against the config
*/
export const evaluateCondition = (condition, config) => {
if (!condition) return true;
return Object.entries(condition).every(([key, value]) => {
// Handle dot notation for nested config (e.g., motor.id)
const keys = key.split('.');
let current = config;
for (const k of keys) {
if (current === null || current === undefined) return false;
current = current[k];
}
return current === value;
});
};
/**
* Check if a component should be included based on config selections
*/
export const shouldIncludeComponent = (componentKey, config) => {
// Actuator is always included (it's the base component)
if (componentKey === 'actuator') {
return true;
}
// Mounting: only if mount is selected
if (componentKey === 'mounting' || componentKey === 'mounts') {
return !!config.mount;
}
// Stand components: only if stand options are selected
if (componentKey === 'stand') {
return !!(config.standFeet || config.standHinge || (config.standCrossbarSupports && config.standCrossbarSupports.length > 0));
}
// Feet: only if standFeet is selected
if (componentKey === 'feet') {
return !!config.standFeet;
}
// Hinges: only if standHinge is selected
if (componentKey === 'hinges') {
return !!config.standHinge;
}
// Crossbar supports: only if standCrossbarSupports are selected
if (componentKey === 'crossbarSupports') {
return !!(config.standCrossbarSupports && config.standCrossbarSupports.length > 0);
}
// Remotes: only if remote is selected
if (componentKey === 'remotes') {
return !!(config.remoteKnob || config.remoteType || config.remote?.id);
}
// Toy mounts: only if toy mount options are selected
if (componentKey === 'toyMounts') {
return !!(config.toyMountOptions && config.toyMountOptions.length > 0);
}
// PCB: only if pcbMount is selected
if (componentKey === 'pcb' || componentKey === 'pcbMount') {
return !!config.pcbMount;
}
// By default, don't include other components unless explicitly selected
return false;
};
/**
* Get minimum price from links or fallback to price field
*/
export const getPriceFromLinks = (item) => {
if (!item) return 0;
if (item.links && item.links.length > 0) {
const prices = item.links.map(link => extractNumericPrice(link.price)).filter(p => p != null && p > 0);
if (prices.length > 0) {
return Math.min(...prices);
}
}
// Fallback to old price field if links don't have prices
return getNumericPrice(item.price);
};
/**
* Get price range or single price from links for display (synchronous version, no conversion)
*/
export const getPriceDisplayFromLinks = (item, targetCurrency = null) => {
if (!item) return 'C$0.00';
if (item.links && item.links.length > 0) {
// Get price objects (with currency) from links, filtering out null/invalid prices
const priceObjects = item.links
.map(link => link.price)
.filter(price => price && (price.amount || (typeof price === 'object' && 'amount' in price)));
if (priceObjects.length === 0) return 'C$0.00';
// If all prices have the same currency, show range with that currency
const currencies = priceObjects
.map(p => p?.currency || 'CAD')
.filter((v, i, a) => a.indexOf(v) === i);
const isSingleCurrency = currencies.length === 1;
// Extract numeric values for min/max calculation
const numericPrices = priceObjects.map(p => {
if (typeof p === 'object' && 'amount' in p) {
const amount = p.amount;
return typeof amount === 'object' && 'min' in amount ? amount.min : (typeof amount === 'number' ? amount : 0);
}
return extractNumericPrice(p);
}).filter(p => p != null && p > 0);
if (numericPrices.length === 0) return 'C$0.00';
const minPrice = Math.min(...numericPrices);
const maxPrice = Math.max(...numericPrices);
if (minPrice === maxPrice) {
// Single price - format with currency from the first link
return formatPrice(priceObjects[0], targetCurrency || 'CAD');
}
// Price range - format both with their respective currencies if different, or same currency if same
if (isSingleCurrency) {
const currency = targetCurrency || currencies[0];
const currencySymbol = currency === 'CAD' ? 'C$' : currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : currency;
return `${currencySymbol}${minPrice.toFixed(2)} - ${currencySymbol}${maxPrice.toFixed(2)}`;
} else {
// Multiple currencies - format each with its currency or target currency
const minPriceObj = priceObjects.find(p => {
const amount = p?.amount;
const val = typeof amount === 'object' && 'min' in amount ? amount.min : (typeof amount === 'number' ? amount : 0);
return val === minPrice;
});
const maxPriceObj = priceObjects.find(p => {
const amount = p?.amount;
const val = typeof amount === 'object' && 'max' in amount ? amount.max : (typeof amount === 'object' && 'min' in amount ? amount.min : (typeof amount === 'number' ? amount : 0));
return val === maxPrice;
});
return `${formatPrice(minPriceObj, targetCurrency || 'CAD')} - ${formatPrice(maxPriceObj, targetCurrency || 'CAD')}`;
}
}
// Fallback to old price field if links don't exist
return formatPrice(item.price || 0, targetCurrency || 'CAD');
};
/**
* Async version with currency conversion
*/
export const getPriceDisplayFromLinksAsync = async (item, targetCurrency = 'CAD', exchangeRates = null) => {
if (!item) return 'C$0.00';
if (item.links && item.links.length > 0) {
// Convert all prices to target currency first
const convertedPrices = await Promise.all(
item.links
.map(link => link.price)
.filter(price => price && (price.amount || (typeof price === 'object' && 'amount' in price)))
.map(async (price) => {
if (exchangeRates) {
return await convertPrice(price, targetCurrency, exchangeRates);
}
return price;
})
);
if (convertedPrices.length === 0) return 'C$0.00';
// Extract numeric values for min/max calculation
const numericPrices = convertedPrices.map(p => {
if (typeof p === 'object' && 'amount' in p) {
const amount = p.amount;
return typeof amount === 'object' && 'min' in amount ? amount.min : (typeof amount === 'number' ? amount : 0);
}
return extractNumericPrice(p);
}).filter(p => p != null && p > 0);
if (numericPrices.length === 0) return 'C$0.00';
const minPrice = Math.min(...numericPrices);
const maxPrice = Math.max(...numericPrices);
if (minPrice === maxPrice) {
return await formatPriceWithConversion(convertedPrices[0], targetCurrency, exchangeRates);
}
// Price range
const currencySymbol = targetCurrency === 'CAD' ? 'C$' : targetCurrency === 'USD' ? '$' : targetCurrency === 'EUR' ? '€' : targetCurrency === 'GBP' ? '£' : targetCurrency;
return `${currencySymbol}${minPrice.toFixed(2)} - ${currencySymbol}${maxPrice.toFixed(2)}`;
}
// Fallback to old price field
if (item.price) {
return await formatPriceWithConversion(item.price, targetCurrency, exchangeRates);
}
return 'C$0.00';
};
/**
* Calculate total hardware cost
*/
export const calculateTotal = (config) => {
let total = 0;
if (config.motor) total += getPriceFromLinks(config.motor);
if (config.powerSupply) total += getPriceFromLinks(config.powerSupply);
if (config.mount) {
const mountOption = partsData.options?.mounts?.find(m => m.id === config.mount.id);
if (mountOption?.hardwareCost) total += getNumericPrice(mountOption.hardwareCost);
}
if (config.standHinge) {
// Check new structure (systems) first, then fall back to options
const hingeSystem = partsData.components?.hinges?.systems?.[config.standHinge.id];
if (hingeSystem?.hardwareCost) {
total += getNumericPrice(hingeSystem.hardwareCost);
} else {
const hingeOption = partsData.options?.standHinges?.find(h => h.id === config.standHinge.id);
if (hingeOption?.hardwareCost) total += getNumericPrice(hingeOption.hardwareCost);
}
}
if (config.standFeet) {
const feetOption = partsData.options?.standFeet?.find(f => f.id === config.standFeet.id);
if (feetOption?.hardwareCost) total += getNumericPrice(feetOption.hardwareCost);
}
if (config.standCrossbarSupports) {
config.standCrossbarSupports.forEach((support) => {
const supportOption = partsData.options?.standCrossbarSupports?.find(s => s.id === support.id);
if (supportOption?.hardwareCost) total += getNumericPrice(supportOption.hardwareCost);
});
}
return total;
};

View File

@@ -0,0 +1,201 @@
/**
* Currency conversion service using exchangerate-api.com
* Free tier: No API key required for basic usage
*/
const CACHE_KEY = 'currency_rates';
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
const API_URL = 'https://api.exchangerate-api.com/v4/latest/CAD';
// Fallback exchange rates (updated manually as backup)
const FALLBACK_RATES = {
CAD: 1.0,
USD: 0.73,
EUR: 0.68,
GBP: 0.58,
AUD: 1.12,
JPY: 109.5,
CNY: 5.28,
};
/**
* Fetch exchange rates from API
*/
const fetchExchangeRates = async () => {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error('Failed to fetch exchange rates');
}
const data = await response.json();
return {
rates: data.rates,
timestamp: Date.now(),
base: data.base || 'CAD',
};
} catch (error) {
console.warn('Failed to fetch exchange rates, using fallback:', error);
return {
rates: FALLBACK_RATES,
timestamp: Date.now(),
base: 'CAD',
};
}
};
/**
* Get cached exchange rates or fetch new ones
*/
export const getExchangeRates = async () => {
// Check cache first
if (typeof window !== 'undefined') {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const { rates, timestamp } = JSON.parse(cached);
const now = Date.now();
// Use cache if it's less than 1 hour old
if (now - timestamp < CACHE_DURATION) {
return rates;
}
} catch (e) {
// Invalid cache, fetch new rates
console.warn('Invalid cache, fetching new rates');
}
}
}
// Fetch new rates
const { rates, timestamp } = await fetchExchangeRates();
// Save to cache
if (typeof window !== 'undefined') {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ rates, timestamp }));
} catch (e) {
console.warn('Failed to cache exchange rates');
}
}
return rates;
};
/**
* Convert amount from source currency to target currency
* @param {number} amount - Amount to convert
* @param {string} fromCurrency - Source currency code (e.g., 'CAD', 'USD')
* @param {string} toCurrency - Target currency code
* @param {object} rates - Exchange rates object (optional, will fetch if not provided)
* @returns {Promise<number>} - Converted amount
*/
export const convertCurrency = async (amount, fromCurrency, toCurrency, rates = null) => {
if (!amount || amount === 0) return 0;
if (fromCurrency === toCurrency) return amount;
// Get rates if not provided
if (!rates) {
rates = await getExchangeRates();
}
// API returns rates with CAD as base, so rates[USD] = 0.73 means 1 CAD = 0.73 USD
// To convert from CAD to USD: amount * rates[USD]
// To convert from USD to CAD: amount / rates[USD]
// To convert from USD to EUR: (amount / rates[USD]) * rates[EUR]
if (fromCurrency === 'CAD') {
const rate = rates[toCurrency] || FALLBACK_RATES[toCurrency] || 1;
return amount * rate;
}
if (toCurrency === 'CAD') {
const rate = rates[fromCurrency] || FALLBACK_RATES[fromCurrency] || 1;
if (rate === 0) return amount; // Avoid division by zero
return amount / rate;
}
// Convert from source -> CAD -> target
const fromRate = rates[fromCurrency] || FALLBACK_RATES[fromCurrency] || 1;
const toRate = rates[toCurrency] || FALLBACK_RATES[toCurrency] || 1;
if (fromRate === 0) return amount; // Avoid division by zero
const amountInCAD = amount / fromRate;
return amountInCAD * toRate;
};
/**
* Convert price object from source currency to target currency
* @param {object|number|string} price - Price to convert (can be price object, number, or string)
* @param {string} targetCurrency - Target currency code
* @param {object} rates - Exchange rates object (optional)
* @returns {Promise<object|string>} - Converted price in same format as input
*/
export const convertPrice = async (price, targetCurrency, rates = null) => {
if (!price) return price;
// Handle price object with currency
if (price && typeof price === 'object' && 'amount' in price) {
const { amount, currency: sourceCurrency = 'CAD' } = price;
if (sourceCurrency === targetCurrency) {
return price; // No conversion needed
}
// Get rates if not provided
if (!rates) {
rates = await getExchangeRates();
}
// Handle price range
if (typeof amount === 'object' && amount.min !== undefined && amount.max !== undefined) {
const minConverted = await convertCurrency(amount.min, sourceCurrency, targetCurrency, rates);
const maxConverted = await convertCurrency(amount.max, sourceCurrency, targetCurrency, rates);
return {
...price,
amount: {
min: minConverted,
max: maxConverted,
},
currency: targetCurrency,
};
}
// Handle single price
if (typeof amount === 'number') {
const converted = await convertCurrency(amount, sourceCurrency, targetCurrency, rates);
return {
...price,
amount: converted,
currency: targetCurrency,
};
}
}
// Handle legacy numeric price (assume CAD)
if (typeof price === 'number') {
if (!rates) {
rates = await getExchangeRates();
}
const converted = await convertCurrency(price, 'CAD', targetCurrency, rates);
return converted;
}
// Handle string price (return as-is, conversion would be complex)
if (typeof price === 'string') {
return price;
}
return price;
};
/**
* Preload exchange rates (call on app init)
*/
export const preloadExchangeRates = async () => {
try {
await getExchangeRates();
} catch (error) {
console.warn('Failed to preload exchange rates:', error);
}
};

View File

@@ -1,4 +1,5 @@
import ExcelJS from 'exceljs';
import { extractNumericPrice, formatPrice } from './priceFormat.js';
// Generate markdown overview
export const generateMarkdownOverview = (config, printedParts, hardwareParts, filamentTotals, totalTime, total) => {
@@ -7,10 +8,24 @@ export const generateMarkdownOverview = (config, printedParts, hardwareParts, fi
md.push('# OSSM Build Configuration');
md.push(`\n**Generated:** ${new Date().toLocaleString()}\n`);
// Helper function to get price display from links or fallback
const getPriceDisplay = (item) => {
if (!item) return 'N/A';
if (item.links && item.links.length > 0) {
const prices = item.links.map(link => extractNumericPrice(link.price)).filter(p => p != null && p > 0);
if (prices.length === 0) return 'N/A';
if (prices.length === 1) return formatPrice(prices[0]);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return minPrice === maxPrice ? formatPrice(minPrice) : `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`;
}
return item.price ? formatPrice(item.price) : 'N/A';
};
// Motor
if (config.motor) {
md.push(`## Motor: ${config.motor.name}`);
md.push(`- **Price:** ${config.motor.price}`);
md.push(`- **Price:** ${getPriceDisplay(config.motor)}`);
md.push(`- **Speed:** ${config.motor.speed}`);
md.push(`- **Wattage:** ${config.motor.wattage}`);
md.push('');
@@ -19,7 +34,7 @@ export const generateMarkdownOverview = (config, printedParts, hardwareParts, fi
// Power Supply
if (config.powerSupply) {
md.push(`## Power Supply: ${config.powerSupply.name}`);
md.push(`- **Price:** ${config.powerSupply.price}`);
md.push(`- **Price:** ${getPriceDisplay(config.powerSupply)}`);
md.push('');
}
@@ -124,49 +139,83 @@ export const generateExcelBOM = (hardwareParts, printedParts, config) => {
// Header
rows.push(['Item', 'Name', 'Quantity', 'Price', 'Link', 'Category', 'Type']);
// Add motor
// Helper function to format price for Excel (handles new price structure and legacy formats)
const formatPriceForExcel = (price) => {
if (price == null) return '';
return formatPrice(price);
};
// Add motor - one row per link option with its price
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'
]);
if (motorLinks.length > 0) {
motorLinks.forEach(link => {
rows.push([
'Motor',
`${config.motor.name} - ${link.store}`,
1,
formatPriceForExcel(link.price),
link.link || '',
'Motor',
'Hardware'
]);
});
} else {
// Fallback if no links
rows.push([
'Motor',
config.motor.name,
1,
config.motor.price || '',
'',
'Motor',
'Hardware'
]);
}
}
// Add power supply
// Add power supply - one row per link option with its price
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'
]);
if (psuLinks.length > 0) {
psuLinks.forEach(link => {
rows.push([
'Power Supply',
`${config.powerSupply.name} - ${link.store}`,
1,
formatPriceForExcel(link.price),
link.link || '',
'Power Supply',
'Hardware'
]);
});
} else {
// Fallback if no links
rows.push([
'Power Supply',
config.powerSupply.name,
1,
config.powerSupply.price || '',
'',
'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'
]);
rows.push([
hw.id || '',
hw.name || '',
hw.quantity || 1,
hw.price ? formatPrice(hw.price) : '',
firstLink,
hw.category || 'Hardware',
'Hardware'
]);
});
// Add printed parts (for reference, not purchase)

View File

@@ -0,0 +1,761 @@
import partsData from '../data/index.js';
import { evaluateCondition, shouldIncludeComponent, getPriceDisplayFromLinks } from './bomUtils';
import { formatPrice } from './priceFormat';
/**
* Categorize hardware by type
*/
export const getHardwareType = (hardware) => {
const id = hardware.id?.toLowerCase() || '';
const name = hardware.name?.toLowerCase() || '';
// Fasteners
if (id.includes('fastener') || id.includes('screw') || id.includes('nut') || id.includes('washer') ||
id.includes('bolt') || id.includes('handle') || name.includes('fastener') || name.includes('screw') ||
name.includes('nut') || name.includes('washer') || name.includes('bolt') || name.includes('handle')) {
return 'Fasteners';
}
// Motion components
if (id.includes('bearing') || id.includes('pulley') || id.includes('belt') || id.includes('gear') ||
id.includes('motor') || id.includes('rail') || id.includes('linear') ||
name.includes('bearing') || name.includes('pulley') || name.includes('belt') ||
name.includes('gear') || name.includes('motor') || name.includes('rail') || name.includes('linear')) {
return 'Motion Components';
}
// Aluminum extrusion / 3030
if (id.includes('3030') || id.includes('extrusion') || id.includes('aluminum') || id.includes('support') ||
name.includes('3030') || name.includes('extrusion') || name.includes('aluminum') || name.includes('90 degree support')) {
return 'Aluminum Extrusion';
}
// Electronics
if (id.includes('pcb') || id.includes('board') || id.includes('circuit') || id.includes('sensor') ||
id.includes('switch') || id.includes('led') || name.includes('pcb') || name.includes('board') ||
name.includes('circuit') || name.includes('sensor') || name.includes('switch') || name.includes('led')) {
return 'Electronics';
}
// Other / General Hardware
return 'Other Hardware';
};
/**
* Get required printed parts based on config
*/
export const getRequiredPrintedParts = (config) => {
const parts = [];
// Always include components that are marked as required and meet their conditions
Object.entries(partsData.components || {}).forEach(([componentKey, component]) => {
// Skip components that don't have selected options (except actuator)
if (!shouldIncludeComponent(componentKey, config)) {
return;
}
const category = component.category || componentKey;
// Handle standard printedParts array
if (component.printedParts) {
component.printedParts.forEach((part) => {
if (part.required && evaluateCondition(part.Condition, config)) {
parts.push({ ...part, category });
}
});
}
// Handle systems (for hinges, remotes, etc.)
if (component.systems) {
// If it's a selected system, include its printed parts
const selectedSystemId = config[componentKey] || config.standHinge; // Fallback for naming mismatches
const system = component.systems[selectedSystemId?.id || selectedSystemId];
if (system) {
const systemParts = system.printedParts || system.bodyParts || [];
systemParts.forEach((part) => {
if (part.required && evaluateCondition(part.Condition, config)) {
parts.push({ ...part, category });
}
});
// Remote knobs are handled by looking up the knob in the system
if (componentKey === 'remotes' && config.remoteKnob) {
const knobPart = system.knobs?.find(k => k.id === config.remoteKnob.id);
if (knobPart) {
parts.push({ ...knobPart, category: 'Remote Knobs' });
}
}
}
}
});
// Handle options that are not explicitly in the "components" top-level structure but represent printed parts
// Mount variations (if not already handled by required parts)
if (config.mount && partsData.components?.mounts?.printedParts) {
const mountPart = partsData.components.mounts.printedParts.find(p => p.id === config.mount.id);
if (mountPart) parts.push({ ...mountPart, category: 'Mount' });
}
// Custom Cover
const coverId = config.cover?.id;
const isStandardCover = coverId === 'standard-cover';
const isBlankCover = coverId === 'blank-cover';
const isCustomCover = config.cover !== null && !isStandardCover && !isBlankCover;
if (isCustomCover) {
const coverOption = config.cover;
parts.push({
id: coverOption.id,
name: coverOption.name,
description: coverOption.description || coverOption.name,
filamentEstimate: coverOption.filamentEstimate ? parseFloat(coverOption.filamentEstimate.replace('~', '').replace('g', '')) : 0,
filePath: `${coverOption.id}.3mf`,
category: 'Cover',
required: true,
colour: 'primary',
});
}
// Stand components (feet, supports)
if (config.standFeet && partsData.components?.feet?.printedParts) {
const feetPart = partsData.components.feet.printedParts.find(p => p.id === config.standFeet.id);
if (feetPart) parts.push({ ...feetPart, category: 'Stand Feet' });
}
if (config.standCrossbarSupports && partsData.components?.crossbarSupports?.printedParts) {
const selectedSupportIds = new Set(config.standCrossbarSupports.map(opt => opt.id));
partsData.components.crossbarSupports.printedParts.forEach((part) => {
if (selectedSupportIds.has(part.id) && !part.isHardwareOnly) {
parts.push({ ...part, category: 'Stand Crossbar Supports' });
}
});
}
// Toy Mounts
if (config.toyMountOptions && config.toyMountOptions.length > 0 && partsData.components?.toyMounts?.printedParts) {
const selectedToyMountIds = new Set(config.toyMountOptions.map(opt => opt.id));
partsData.components.toyMounts.printedParts.forEach((part) => {
if (selectedToyMountIds.has(part.id)) {
parts.push({ ...part, category: 'Toy Mounts' });
}
});
}
// Handle 'replaces' logic
const replacedIds = new Set();
parts.forEach(part => {
if (part.replaces) {
part.replaces.forEach(id => replacedIds.add(id));
}
});
return parts.filter(part => !replacedIds.has(part.id));
};
/**
* Get required hardware parts based on config
*/
export const getRequiredHardwareParts = (config) => {
const printedParts = getRequiredPrintedParts(config);
const printedPartIds = new Set(printedParts.map(p => p.id));
const hardwareParts = [];
const hardwareMap = new Map(); // To aggregate quantities for same hardware part
// Collect all selected option IDs (including hardware-only options)
const selectedOptionIds = new Set();
// Add selected crossbar supports (may include hardware-only options)
if (config.standCrossbarSupports && config.standCrossbarSupports.length > 0) {
config.standCrossbarSupports.forEach(opt => selectedOptionIds.add(opt.id));
}
// Add other selected options that might be hardware-only
if (config.standHinge) selectedOptionIds.add(config.standHinge.id);
if (config.standFeet) selectedOptionIds.add(config.standFeet.id);
if (config.mount) selectedOptionIds.add(config.mount.id);
if (config.cover) selectedOptionIds.add(config.cover.id);
if (config.pcbMount) selectedOptionIds.add(config.pcbMount.id);
if (config.remoteKnob) selectedOptionIds.add(config.remoteKnob.id);
if (config.toyMountOptions && config.toyMountOptions.length > 0) {
config.toyMountOptions.forEach(opt => selectedOptionIds.add(opt.id));
}
// Handle hinges systems (new structure)
if (config.standHinge && partsData.components?.hinges?.systems) {
const hingeSystem = partsData.components.hinges.systems[config.standHinge.id];
if (hingeSystem?.hardwareParts) {
hingeSystem.hardwareParts.forEach((hardware) => {
if (!hardware.required) return;
// Evaluate condition for hardware
if (!evaluateCondition(hardware.Condition, config)) return;
const key = hardware.id;
if (hardwareMap.has(key)) {
const existing = hardwareMap.get(key);
existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1);
} else {
hardwareMap.set(key, {
...hardware,
quantity: hardware.quantity || 1,
category: partsData.components.hinges.category || 'Hardware',
hardwareType: getHardwareType(hardware)
});
}
});
}
}
// Handle remote systems (new structure)
if (config.remoteKnob && partsData.components?.remotes?.systems) {
// Find which system contains this knob
let remoteSystem = null;
Object.values(partsData.components.remotes.systems).forEach((system) => {
if (system.knobs && system.knobs.find(k => k.id === config.remoteKnob.id)) {
remoteSystem = system;
}
});
if (remoteSystem?.hardwareParts) {
remoteSystem.hardwareParts.forEach((hardware) => {
if (!hardware.required) return;
// Evaluate condition for hardware
if (!evaluateCondition(hardware.Condition, config)) return;
const key = hardware.id;
if (hardwareMap.has(key)) {
const existing = hardwareMap.get(key);
existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1);
} else {
hardwareMap.set(key, {
...hardware,
quantity: hardware.quantity || 1,
category: partsData.components.remotes.category || 'Hardware'
});
}
});
}
}
// Build a map of component keys to their printed part IDs for quick lookup
const componentPrintedPartIds = new Map();
Object.entries(partsData.components || {}).forEach(([componentKey, component]) => {
// Skip hinges and remotes as they're handled separately above
if (componentKey === 'hinges' || componentKey === 'remotes') return;
// Skip components that don't have selected options (except actuator)
if (!shouldIncludeComponent(componentKey, config)) {
return;
}
if (component.printedParts) {
const partIds = component.printedParts.map(p => p.id);
componentPrintedPartIds.set(componentKey, new Set(partIds));
}
});
// Iterate through all components to find hardware parts (excluding hinges and remotes)
Object.entries(partsData.components || {}).forEach(([componentKey, component]) => {
// Skip hinges and remotes as they're handled separately above
if (componentKey === 'hinges' || componentKey === 'remotes') return;
// Skip components that don't have selected options (except actuator)
if (!shouldIncludeComponent(componentKey, config)) {
return;
}
// Handle components with systems (like mounts)
if (component.systems) {
// Find the selected system based on config
let selectedSystemId = null;
if (componentKey === 'mounting' && config.mount) {
selectedSystemId = config.mount.id;
} else if (componentKey === 'toyMounts' && config.toyMountOptions && config.toyMountOptions.length > 0) {
// For toy mounts, process all selected options
config.toyMountOptions.forEach((toyMount) => {
const system = component.systems[toyMount.id];
if (system?.hardwareParts) {
system.hardwareParts.forEach((hardware) => {
if (!hardware.required) return;
if (!evaluateCondition(hardware.Condition, config)) return;
const key = hardware.id;
if (hardwareMap.has(key)) {
const existing = hardwareMap.get(key);
existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1);
} else {
hardwareMap.set(key, {
...hardware,
quantity: hardware.quantity || 1,
category: component.category || 'Hardware',
hardwareType: getHardwareType(hardware)
});
}
});
}
});
return; // Skip the rest for toy mounts
}
if (selectedSystemId) {
const system = component.systems[selectedSystemId];
if (system?.hardwareParts) {
system.hardwareParts.forEach((hardware) => {
if (!hardware.required) return;
if (!evaluateCondition(hardware.Condition, config)) return;
const key = hardware.id;
if (hardwareMap.has(key)) {
const existing = hardwareMap.get(key);
existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1);
} else {
hardwareMap.set(key, {
...hardware,
quantity: hardware.quantity || 1,
category: component.category || 'Hardware',
hardwareType: getHardwareType(hardware)
});
}
});
}
}
return; // Skip direct hardwareParts check for components with systems
}
// Handle components with direct hardwareParts (old structure)
if (!component.hardwareParts) return;
// Check if this component has any selected printed parts OR selected options
const componentPartIds = componentPrintedPartIds.get(componentKey);
const hasSelectedParts = componentPartIds && Array.from(componentPartIds).some(id => printedPartIds.has(id) || selectedOptionIds.has(id));
component.hardwareParts.forEach((hardware) => {
if (!hardware.required) return;
// Evaluate condition for hardware
if (!evaluateCondition(hardware.Condition, config)) return;
// If component has selected parts, check if hardware should be included
let shouldInclude = false;
if (hasSelectedParts) {
const relatedParts = hardware.relatedParts || [];
// If no relatedParts specified, include if component has selected parts
// If relatedParts specified, include if any related part is selected (printed or option)
if (relatedParts.length === 0) {
shouldInclude = true;
} else {
shouldInclude = relatedParts.some(partId => printedPartIds.has(partId) || selectedOptionIds.has(partId));
}
}
if (shouldInclude) {
const key = hardware.id;
if (hardwareMap.has(key)) {
// Aggregate quantities if same hardware appears multiple times
const existing = hardwareMap.get(key);
existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1);
} else {
hardwareMap.set(key, {
...hardware,
quantity: hardware.quantity || 1,
category: component.category || 'Hardware',
hardwareType: getHardwareType(hardware)
});
}
}
});
});
// Add Motor, Power Supply, and PCB to hardware list
// Store original item reference for price conversion
if (config.motor) {
hardwareParts.push({
id: config.motor.id || 'motor',
name: config.motor.name || 'Motor',
description: config.motor.description || '',
quantity: 1,
category: 'Electronics',
hardwareType: 'Electronics',
price: config.motor, // Store original item for price conversion
_isItemReference: true // Flag to indicate this is an item reference
});
}
if (config.powerSupply) {
hardwareParts.push({
id: config.powerSupply.id || 'power-supply',
name: config.powerSupply.name || 'Power Supply',
description: config.powerSupply.description || '',
quantity: 1,
category: 'Electronics',
hardwareType: 'Electronics',
price: config.powerSupply, // Store original item for price conversion
_isItemReference: true
});
}
// Add PCB (always required, separate from PCB mount)
if (partsData.pcbs && partsData.pcbs.length > 0) {
const pcb = partsData.pcbs[0]; // Get the first/only PCB
hardwareParts.push({
id: pcb.id || 'pcb',
name: pcb.name || 'PCB',
description: pcb.description || '',
quantity: 1,
category: 'Electronics',
hardwareType: 'Electronics',
price: pcb, // Store original item for price conversion
_isItemReference: true
});
}
// Convert map to array
hardwareMap.forEach((hardware) => {
hardwareParts.push(hardware);
});
return hardwareParts;
};
/**
* Parse time string to minutes
*/
export const parseTimeToMinutes = (timeStr) => {
if (!timeStr || typeof timeStr !== 'string') return 0;
let totalMinutes = 0;
const hourMatch = timeStr.match(/(\d+)h/);
const minuteMatch = timeStr.match(/(\d+)m/);
const secondMatch = timeStr.match(/(\d+)s/);
if (hourMatch) totalMinutes += parseInt(hourMatch[1], 10) * 60;
if (minuteMatch) totalMinutes += parseInt(minuteMatch[1], 10);
if (secondMatch) totalMinutes += parseFloat(secondMatch[1]) / 60;
return totalMinutes;
};
/**
* Format minutes to readable string (e.g., "2h 14m")
*/
export const formatTimeFromMinutes = (minutes) => {
if (minutes === 0) return '0m';
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
if (hours > 0 && mins > 0) {
return `${hours}h ${mins}m`;
} else if (hours > 0) {
return `${hours}h`;
} else {
return `${mins}m`;
}
};
/**
* Calculate total filament estimate
*/
export const getTotalFilamentEstimate = (printedParts) => {
const totals = {
primary: 0,
secondary: 0,
total: 0
};
printedParts.forEach((part) => {
let estimate = 0;
// Handle both numeric and string values (e.g., "~147g" or 147.19)
if (typeof part.filamentEstimate === 'number') {
estimate = part.filamentEstimate;
} else if (typeof part.filamentEstimate === 'string') {
// Parse string format like "~147g" or "147g"
const cleaned = part.filamentEstimate.replace(/[~g]/g, '').trim();
estimate = parseFloat(cleaned) || 0;
}
// Multiply by quantity if specified (default to 1)
const quantity = part.quantity || 1;
estimate = estimate * quantity;
const colour = part.colour || 'primary';
if (colour === 'primary') {
totals.primary += estimate;
} else if (colour === 'secondary') {
totals.secondary += estimate;
}
totals.total += estimate;
});
return totals;
};
/**
* Calculate total time estimate
*/
export const getTotalTimeEstimate = (printedParts) => {
let totalMinutes = 0;
printedParts.forEach((part) => {
if (part.timeEstimate) {
const timeMinutes = parseTimeToMinutes(part.timeEstimate);
// Multiply by quantity if specified (default to 1)
const quantity = part.quantity || 1;
totalMinutes += timeMinutes * quantity;
}
});
return formatTimeFromMinutes(totalMinutes);
};
/**
* Get color name from color ID
*/
export const getColorName = (colorId, type = 'primary') => {
const colors = type === 'primary' ? partsData.colors.primary : partsData.colors.accent;
const color = colors.find((c) => c.id === colorId);
return color ? color.name : colorId;
};
/**
* Get color hex from color ID
*/
export const getColorHex = (colorId, type = 'primary') => {
const colors = type === 'primary' ? partsData.colors.primary : partsData.colors.accent;
const color = colors.find((c) => c.id === colorId);
return color ? color.hex : '#000000';
};
/**
* Get expanded hardware parts grouped by component
*/
export const getExpandedHardwareParts = (config) => {
const printedParts = getRequiredPrintedParts(config);
const printedPartIds = new Set(printedParts.map(p => p.id));
const expandedHardware = [];
const selectedOptionIds = new Set();
// Add Electronics section with Motor, Power Supply, and PCB
const electronicsHardware = [];
if (config.motor) {
electronicsHardware.push({
id: config.motor.id || 'motor',
name: config.motor.name || 'Motor',
description: config.motor.description || '',
quantity: 1,
hardwareType: 'Electronics',
price: config.motor, // Store original item for price conversion
_isItemReference: true
});
}
if (config.powerSupply) {
electronicsHardware.push({
id: config.powerSupply.id || 'power-supply',
name: config.powerSupply.name || 'Power Supply',
description: config.powerSupply.description || '',
quantity: 1,
hardwareType: 'Electronics',
price: config.powerSupply, // Store original item for price conversion
_isItemReference: true
});
}
// Add PCB (always required, separate from PCB mount)
if (partsData.pcbs && partsData.pcbs.length > 0) {
const pcb = partsData.pcbs[0]; // Get the first/only PCB
electronicsHardware.push({
id: pcb.id || 'pcb',
name: pcb.name || 'PCB',
description: pcb.description || '',
quantity: 1,
hardwareType: 'Electronics',
price: pcb, // Store original item for price conversion
_isItemReference: true
});
}
if (electronicsHardware.length > 0) {
expandedHardware.push({
component: 'Electronics',
parts: electronicsHardware
});
}
if (config.standCrossbarSupports && config.standCrossbarSupports.length > 0) {
config.standCrossbarSupports.forEach(opt => selectedOptionIds.add(opt.id));
}
if (config.standHinge) selectedOptionIds.add(config.standHinge.id);
if (config.standFeet) selectedOptionIds.add(config.standFeet.id);
if (config.mount) selectedOptionIds.add(config.mount.id);
if (config.cover) selectedOptionIds.add(config.cover.id);
if (config.pcbMount) selectedOptionIds.add(config.pcbMount.id);
if (config.remoteKnob) selectedOptionIds.add(config.remoteKnob.id);
if (config.toyMountOptions && config.toyMountOptions.length > 0) {
config.toyMountOptions.forEach(opt => selectedOptionIds.add(opt.id));
}
// Handle hinges systems
if (config.standHinge && partsData.components?.hinges?.systems) {
const hingeSystem = partsData.components.hinges.systems[config.standHinge.id];
if (hingeSystem?.hardwareParts) {
const componentHardware = [];
hingeSystem.hardwareParts.forEach((hardware) => {
if (hardware.required) {
componentHardware.push({
...hardware,
quantity: hardware.quantity || 1,
hardwareType: getHardwareType(hardware)
});
}
});
if (componentHardware.length > 0) {
expandedHardware.push({
component: partsData.components.hinges.category || 'Hinges',
parts: componentHardware,
});
}
}
}
// Handle remote systems
if (config.remoteKnob && partsData.components?.remotes?.systems) {
let remoteSystem = null;
Object.values(partsData.components.remotes.systems).forEach((system) => {
if (system.knobs && system.knobs.find(k => k.id === config.remoteKnob.id)) {
remoteSystem = system;
}
});
if (remoteSystem?.hardwareParts) {
const componentHardware = [];
remoteSystem.hardwareParts.forEach((hardware) => {
if (hardware.required) {
componentHardware.push({
...hardware,
quantity: hardware.quantity || 1,
hardwareType: getHardwareType(hardware)
});
}
});
if (componentHardware.length > 0) {
expandedHardware.push({
component: partsData.components.remotes.category || 'Remote',
parts: componentHardware,
});
}
}
}
// Handle mount systems
if (config.mount && partsData.components?.mounting?.systems) {
const mountSystem = partsData.components.mounting.systems[config.mount.id];
if (mountSystem?.hardwareParts) {
const componentHardware = [];
mountSystem.hardwareParts.forEach((hardware) => {
if (hardware.required && evaluateCondition(hardware.Condition, config)) {
componentHardware.push({
...hardware,
quantity: hardware.quantity || 1,
hardwareType: getHardwareType(hardware)
});
}
});
if (componentHardware.length > 0) {
expandedHardware.push({
component: partsData.components.mounting.category || 'Mounting',
parts: componentHardware,
});
}
}
}
// Handle toy mount systems
if (config.toyMountOptions && config.toyMountOptions.length > 0 && partsData.components?.toyMounts?.systems) {
config.toyMountOptions.forEach((toyMount) => {
const toyMountSystem = partsData.components.toyMounts.systems[toyMount.id];
if (toyMountSystem?.hardwareParts) {
const componentHardware = [];
toyMountSystem.hardwareParts.forEach((hardware) => {
if (hardware.required && evaluateCondition(hardware.Condition, config)) {
componentHardware.push({
...hardware,
quantity: hardware.quantity || 1,
hardwareType: getHardwareType(hardware)
});
}
});
if (componentHardware.length > 0) {
expandedHardware.push({
component: partsData.components.toyMounts.category || 'Toy Mounts',
parts: componentHardware,
});
}
}
});
}
// Handle other components
const componentPrintedPartIds = new Map();
Object.entries(partsData.components || {}).forEach(([componentKey, component]) => {
if (componentKey === 'hinges' || componentKey === 'remotes' || componentKey === 'mounting' || componentKey === 'toyMounts') return;
// Skip components that don't have selected options (except actuator)
if (!shouldIncludeComponent(componentKey, config)) {
return;
}
if (component.printedParts) {
const partIds = component.printedParts.map(p => p.id);
componentPrintedPartIds.set(componentKey, new Set(partIds));
}
});
Object.entries(partsData.components || {}).forEach(([componentKey, component]) => {
if (componentKey === 'hinges' || componentKey === 'remotes' || componentKey === 'mounting' || componentKey === 'toyMounts') return;
// Skip components that don't have selected options (except actuator)
if (!shouldIncludeComponent(componentKey, config)) {
return;
}
if (!component.hardwareParts) return;
const componentPartIds = componentPrintedPartIds.get(componentKey);
const hasSelectedParts = componentPartIds && Array.from(componentPartIds).some(id => printedPartIds.has(id) || selectedOptionIds.has(id));
const componentHardware = [];
component.hardwareParts.forEach((hardware) => {
if (!hardware.required) return;
let shouldInclude = false;
if (hasSelectedParts) {
const relatedParts = hardware.relatedParts || [];
if (relatedParts.length === 0) {
shouldInclude = true;
} else {
shouldInclude = relatedParts.some(partId => printedPartIds.has(partId) || selectedOptionIds.has(partId));
}
}
if (shouldInclude) {
componentHardware.push({
...hardware,
quantity: hardware.quantity || 1,
hardwareType: getHardwareType(hardware)
});
}
});
if (componentHardware.length > 0) {
expandedHardware.push({
component: component.category || componentKey,
parts: componentHardware,
});
}
});
return expandedHardware;
};

View File

@@ -1,26 +1,114 @@
// Helper function to format price (handles both number and string prices)
export function formatPrice(price) {
import { convertPrice } from './currencyService';
// Helper function to get currency symbol
export function getCurrencySymbol(currency) {
const symbols = {
'USD': '$',
'EUR': '€',
'GBP': '£',
'CAD': 'C$',
'AUD': 'A$',
'JPY': '¥',
'CNY': '¥',
};
return symbols[currency?.toUpperCase()] || currency?.toUpperCase() || 'C$';
}
// Helper function to format price (handles new price object structure and legacy formats)
// Note: This is the synchronous version. For currency conversion, use the async version or hook
export function formatPrice(price, displayCurrency = 'CAD') {
// Handle new price structure with currency
if (price && typeof price === 'object' && 'amount' in price) {
const { amount, currency = displayCurrency } = price;
// Use the currency from the price object, or fall back to displayCurrency
const finalCurrency = currency || displayCurrency;
if (typeof amount === 'object' && amount.min !== undefined && amount.max !== undefined) {
// Price range
const currencySymbol = getCurrencySymbol(finalCurrency);
if (amount.min === amount.max) {
return `${currencySymbol}${amount.min.toFixed(2)}`;
}
return `${currencySymbol}${amount.min.toFixed(2)} - ${currencySymbol}${amount.max.toFixed(2)}`;
}
if (typeof amount === 'number') {
// Single price
const currencySymbol = getCurrencySymbol(finalCurrency);
return `${currencySymbol}${amount.toFixed(2)}`;
}
}
// Legacy format support for backward compatibility
// Use displayCurrency for legacy formats
if (typeof price === 'number') {
return `$${price.toFixed(2)}`;
const currencySymbol = getCurrencySymbol(displayCurrency);
return `${currencySymbol}${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}`;
// But try to convert symbol if it's just a dollar sign and displayCurrency is different
if (price.startsWith('$') && displayCurrency !== 'USD') {
const currencySymbol = getCurrencySymbol(displayCurrency);
return price.replace('$', currencySymbol);
}
return price.startsWith('$') || price.match(/^[€£¥C$A$]/) ? price : `${getCurrencySymbol(displayCurrency)}${price}`;
}
return '$0.00';
return `${getCurrencySymbol(displayCurrency)}0.00`;
}
// Helper function to get numeric price for calculations (returns 0 for string prices)
/**
* Async version that converts currency before formatting
*/
export async function formatPriceWithConversion(price, targetCurrency, exchangeRates = null) {
if (!price) return formatPrice(price, targetCurrency);
try {
const converted = await convertPrice(price, targetCurrency, exchangeRates);
return formatPrice(converted, targetCurrency);
} catch (error) {
console.warn('Failed to convert price, formatting without conversion:', error);
return formatPrice(price, targetCurrency);
}
}
// Helper function to get numeric price for calculations
export function getNumericPrice(price) {
// Handle new price structure with currency
if (price && typeof price === 'object' && 'amount' in price) {
const { amount } = price;
if (typeof amount === 'object' && amount.min !== undefined) {
// For ranges, return the minimum
return amount.min || 0;
}
if (typeof amount === 'number') {
return amount;
}
}
// Legacy format support
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+/);
// Try to extract a number from string prices like "$125-$250" or "206.96-234.00"
if (price.includes('-')) {
const range = price.split('-').map(p => parseFloat(p.trim().replace(/[^0-9.]/g, ''))).filter(p => !isNaN(p));
return range.length > 0 ? Math.min(...range) : 0;
}
const match = price.match(/[\d.]+/);
if (match) {
return parseFloat(match[0]);
}
}
return 0;
}
// Helper function to extract numeric price (handles both new and legacy formats)
export function extractNumericPrice(price) {
if (price == null) return null;
const numeric = getNumericPrice(price);
return numeric != null && !isNaN(numeric) ? numeric : null;
}