Files
ossm-configurator/website/src/utils/partUtils.js
MunchDev-oss aba0964a59 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.
2026-01-10 03:04:28 -05:00

762 lines
27 KiB
JavaScript

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;
};