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:
7
website/package-lock.json
generated
7
website/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -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
201
website/src/components/BOMSummary/HardwareTab.jsx
Normal file
201
website/src/components/BOMSummary/HardwareTab.jsx
Normal 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,
|
||||
};
|
||||
254
website/src/components/BOMSummary/OverviewTab.jsx
Normal file
254
website/src/components/BOMSummary/OverviewTab.jsx
Normal 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,
|
||||
};
|
||||
222
website/src/components/BOMSummary/PrintedPartsTab.jsx
Normal file
222
website/src/components/BOMSummary/PrintedPartsTab.jsx
Normal 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,
|
||||
};
|
||||
36
website/src/components/BOMSummary/ShareButton.jsx
Normal file
36
website/src/components/BOMSummary/ShareButton.jsx
Normal 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,
|
||||
};
|
||||
88
website/src/components/CurrencySwitcher.jsx
Normal file
88
website/src/components/CurrencySwitcher.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,12 +19,27 @@ 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'
|
||||
@@ -25,24 +47,18 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
}`}
|
||||
>
|
||||
{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
|
||||
<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`}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
containerClassName={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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,26 +96,33 @@ 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">
|
||||
<>
|
||||
<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 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="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"
|
||||
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 h-3 mr-1.5"
|
||||
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"
|
||||
@@ -111,11 +134,31 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{link.store}
|
||||
</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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
<ImageWithFallback
|
||||
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';
|
||||
}}
|
||||
containerClassName="mb-3 flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
|
||||
@@ -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
|
||||
<ImageWithFallback
|
||||
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';
|
||||
}}
|
||||
containerClassName="mb-4 flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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,26 +105,33 @@ 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">
|
||||
<>
|
||||
<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 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="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"
|
||||
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 h-3 mr-1.5"
|
||||
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"
|
||||
@@ -122,11 +143,31 @@ export default function PowerSupplyStep({ config, updateConfig }) {
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{link.store}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
<ImageWithFallback
|
||||
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';
|
||||
}}
|
||||
containerClassName="mb-3 flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
|
||||
@@ -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
|
||||
<ImageWithFallback
|
||||
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-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
|
||||
63
website/src/components/ui/AsyncPrice.jsx
Normal file
63
website/src/components/ui/AsyncPrice.jsx
Normal 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,
|
||||
};
|
||||
56
website/src/components/ui/DataTable.jsx
Normal file
56
website/src/components/ui/DataTable.jsx
Normal 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,
|
||||
};
|
||||
204
website/src/components/ui/ExportButton.jsx
Normal file
204
website/src/components/ui/ExportButton.jsx
Normal 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,
|
||||
};
|
||||
76
website/src/components/ui/FilamentDisplay.jsx
Normal file
76
website/src/components/ui/FilamentDisplay.jsx
Normal 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,
|
||||
};
|
||||
40
website/src/components/ui/ImageWithFallback.jsx
Normal file
40
website/src/components/ui/ImageWithFallback.jsx
Normal 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,
|
||||
};
|
||||
83
website/src/components/ui/OptionCard.jsx
Normal file
83
website/src/components/ui/OptionCard.jsx
Normal 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,
|
||||
};
|
||||
43
website/src/components/ui/PriceDisplay.jsx
Normal file
43
website/src/components/ui/PriceDisplay.jsx
Normal 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,
|
||||
};
|
||||
40
website/src/components/ui/TabNavigation.jsx
Normal file
40
website/src/components/ui/TabNavigation.jsx
Normal 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,
|
||||
};
|
||||
7
website/src/components/ui/index.js
Normal file
7
website/src/components/ui/index.js
Normal 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';
|
||||
84
website/src/contexts/CurrencyContext.jsx
Normal file
84
website/src/contexts/CurrencyContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
21
website/src/data/components/pcb.json
Normal file
21
website/src/data/components/pcb.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
website/src/hooks/usePriceFormat.js
Normal file
39
website/src/hooks/usePriceFormat.js
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
<CurrencyProvider>
|
||||
<App />
|
||||
</CurrencyProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
244
website/src/utils/bomUtils.js
Normal file
244
website/src/utils/bomUtils.js
Normal 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;
|
||||
};
|
||||
201
website/src/utils/currencyService.js
Normal file
201
website/src/utils/currencyService.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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,35 +139,69 @@ 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 : '';
|
||||
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,
|
||||
firstLink,
|
||||
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 : '';
|
||||
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,
|
||||
firstLink,
|
||||
config.powerSupply.price || '',
|
||||
'',
|
||||
'Power Supply',
|
||||
'Hardware'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add hardware parts
|
||||
hardwareParts.forEach(hw => {
|
||||
@@ -162,7 +211,7 @@ export const generateExcelBOM = (hardwareParts, printedParts, config) => {
|
||||
hw.id || '',
|
||||
hw.name || '',
|
||||
hw.quantity || 1,
|
||||
hw.price ? `$${parseFloat(hw.price).toFixed(2)}` : '',
|
||||
hw.price ? formatPrice(hw.price) : '',
|
||||
firstLink,
|
||||
hw.category || 'Hardware',
|
||||
'Hardware'
|
||||
|
||||
761
website/src/utils/partUtils.js
Normal file
761
website/src/utils/partUtils.js
Normal 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;
|
||||
};
|
||||
@@ -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 '$0.00';
|
||||
return price.startsWith('$') || price.match(/^[€£¥C$A$]/) ? price : `${getCurrencySymbol(displayCurrency)}${price}`;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user