From aba0964a59e6f1d8ea772c1f4ded0c4c3e370944 Mon Sep 17 00:00:00 2001 From: MunchDev-oss Date: Sat, 10 Jan 2026 03:04:28 -0500 Subject: [PATCH] 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. --- website/package-lock.json | 7 +- website/package.json | 1 + website/src/App.jsx | 28 +- website/src/components/BOMSummary.jsx | 1653 +---------------- .../src/components/BOMSummary/HardwareTab.jsx | 201 ++ .../src/components/BOMSummary/OverviewTab.jsx | 254 +++ .../components/BOMSummary/PrintedPartsTab.jsx | 222 +++ .../src/components/BOMSummary/ShareButton.jsx | 36 + website/src/components/CurrencySwitcher.jsx | 88 + website/src/components/ThemeToggle.jsx | 2 +- website/src/components/Wizard.jsx | 14 +- website/src/components/steps/MotorStep.jsx | 172 +- website/src/components/steps/OptionsStep.jsx | 19 +- .../src/components/steps/PowerSupplyStep.jsx | 143 +- website/src/components/steps/RemoteStep.jsx | 19 +- website/src/components/steps/ToyMountStep.jsx | 20 +- website/src/components/ui/AsyncPrice.jsx | 63 + website/src/components/ui/DataTable.jsx | 56 + website/src/components/ui/ExportButton.jsx | 204 ++ website/src/components/ui/FilamentDisplay.jsx | 76 + .../src/components/ui/ImageWithFallback.jsx | 40 + website/src/components/ui/OptionCard.jsx | 83 + website/src/components/ui/PriceDisplay.jsx | 43 + website/src/components/ui/TabNavigation.jsx | 40 + website/src/components/ui/index.js | 7 + website/src/contexts/CurrencyContext.jsx | 84 + website/src/data/common/hardware.json | 145 +- website/src/data/components/actuator.json | 8 - website/src/data/components/motors.json | 56 +- website/src/data/components/pcb.json | 21 + .../src/data/components/powerSupplies.json | 46 +- website/src/data/components/stand.json | 36 +- website/src/data/index.js | 2 + website/src/hooks/usePriceFormat.js | 39 + website/src/main.jsx | 9 +- website/src/utils/bomUtils.js | 244 +++ website/src/utils/currencyService.js | 201 ++ website/src/utils/exportUtils.js | 115 +- website/src/utils/partUtils.js | 761 ++++++++ website/src/utils/priceFormat.js | 104 +- 40 files changed, 3519 insertions(+), 1843 deletions(-) create mode 100644 website/src/components/BOMSummary/HardwareTab.jsx create mode 100644 website/src/components/BOMSummary/OverviewTab.jsx create mode 100644 website/src/components/BOMSummary/PrintedPartsTab.jsx create mode 100644 website/src/components/BOMSummary/ShareButton.jsx create mode 100644 website/src/components/CurrencySwitcher.jsx create mode 100644 website/src/components/ui/AsyncPrice.jsx create mode 100644 website/src/components/ui/DataTable.jsx create mode 100644 website/src/components/ui/ExportButton.jsx create mode 100644 website/src/components/ui/FilamentDisplay.jsx create mode 100644 website/src/components/ui/ImageWithFallback.jsx create mode 100644 website/src/components/ui/OptionCard.jsx create mode 100644 website/src/components/ui/PriceDisplay.jsx create mode 100644 website/src/components/ui/TabNavigation.jsx create mode 100644 website/src/components/ui/index.js create mode 100644 website/src/contexts/CurrencyContext.jsx create mode 100644 website/src/data/components/pcb.json create mode 100644 website/src/hooks/usePriceFormat.js create mode 100644 website/src/utils/bomUtils.js create mode 100644 website/src/utils/currencyService.js create mode 100644 website/src/utils/partUtils.js diff --git a/website/package-lock.json b/website/package-lock.json index f821cab..2d4408e 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -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", diff --git a/website/package.json b/website/package.json index 0f46c72..5eba6bc 100644 --- a/website/package.json +++ b/website/package.json @@ -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" }, diff --git a/website/src/App.jsx b/website/src/App.jsx index 69288b6..e6536cf 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -2,14 +2,21 @@ import { useState, useEffect } from 'react'; import MainPage from './components/MainPage'; import Wizard from './components/Wizard'; import ThemeToggle from './components/ThemeToggle'; +import CurrencySwitcher from './components/CurrencySwitcher'; import partsData from './data/index.js'; import { getSharedConfig } from './utils/shareService'; function App() { const [buildType, setBuildType] = useState(null); + // Determine initial recommended parts + const recommendedMotor = partsData.motors.find(m => m.recommended) || partsData.motors[0]; + const recommendedPSU = partsData.powerSupplies.find(psu => + psu.compatibleMotors.includes(recommendedMotor.id) + ) || partsData.powerSupplies[0]; + const [config, setConfig] = useState({ - motor: null, - powerSupply: null, + motor: recommendedMotor, + powerSupply: recommendedPSU, primaryColor: 'black', accentColor: 'black', mount: null, @@ -29,7 +36,7 @@ function App() { const urlParams = new URLSearchParams(window.location.search); const shareId = urlParams.get('share'); const isSession = urlParams.get('session') === 'true'; - + if (shareId) { const sharedConfig = getSharedConfig(shareId, isSession); if (sharedConfig) { @@ -63,10 +70,10 @@ function App() { // - Standard colors (black/black) // - Basic stand components // - Default toy mount options (flange mount base) - + const motor = partsData.motors.find(m => m.id === '57AIM30') || partsData.motors[0]; const powerSupply = partsData.powerSupplies.find(ps => ps.id === 'psu-24v-5a') || partsData.powerSupplies[0]; - + // Get mount from options data to ensure proper structure const mountOptions = partsData.options?.actuator?.sections?.mounts?.options || []; const mount = mountOptions.find(m => m.id === 'middle-pivot') || mountOptions[0] || null; @@ -127,9 +134,14 @@ function App() { const handleBackToMain = () => { setBuildType(null); + const defaultMotor = partsData.motors.find(m => m.recommended) || partsData.motors[0]; + const defaultPSU = partsData.powerSupplies.find(psu => + psu.compatibleMotors.includes(defaultMotor.id) + ) || partsData.powerSupplies[0]; + setConfig({ - motor: null, - powerSupply: null, + motor: defaultMotor, + powerSupply: defaultPSU, primaryColor: 'black', accentColor: 'black', mount: null, @@ -149,6 +161,7 @@ function App() { return ( <> + ); @@ -157,6 +170,7 @@ function App() { return ( <> + { - 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; - }); - }; - - const calculateTotal = () => { - let total = 0; - - if (config.motor) total += getNumericPrice(config.motor.price); - if (config.powerSupply) total += getNumericPrice(config.powerSupply.price); - - 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; - }; - - const getRequiredPrintedParts = () => { - const parts = []; - - // Always include components that are marked as required and meet their conditions - Object.entries(partsData.components || {}).forEach(([componentKey, component]) => { - 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)); - }; - - // Helper function to categorize hardware by type - 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'; - }; - - const getRequiredHardwareParts = () => { - const printedParts = getRequiredPrintedParts(); - 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; - - 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; - - // 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) - }); - } - } - }); - }); - - // Convert map to array - hardwareMap.forEach((hardware) => { - hardwareParts.push(hardware); - }); - - return hardwareParts; - }; - - 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") - 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`; - } - }; - - const getTotalFilamentEstimate = () => { - const parts = getRequiredPrintedParts(); - const totals = { - primary: 0, - secondary: 0, - total: 0 - }; - - parts.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; - }; - - const getTotalTimeEstimate = () => { - const parts = getRequiredPrintedParts(); - let totalMinutes = 0; - - parts.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); - }; - - 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; - }; - - 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'; - }; - - const total = calculateTotal(); - const printedParts = getRequiredPrintedParts(); - const hardwareParts = getRequiredHardwareParts(); - const filamentTotals = getTotalFilamentEstimate(); - const totalTime = getTotalTimeEstimate(); - - // Group parts by category - const partsByCategory = printedParts.reduce((acc, part) => { - if (!acc[part.category]) { - acc[part.category] = []; - } - acc[part.category].push(part); - return acc; - }, {}); - - // Group hardware parts by category (unified view - aggregated quantities) - const hardwareByCategory = hardwareParts.reduce((acc, part) => { - if (!acc[part.category]) { - acc[part.category] = []; - } - acc[part.category].push(part); - return acc; - }, {}); - - // Group hardware parts by type for unified view (Fasteners, Motion Components, etc.) - const hardwareByType = hardwareParts.reduce((acc, part) => { - const type = part.hardwareType || 'Other Hardware'; - if (!acc[type]) { - acc[type] = []; - } - acc[type].push(part); - return acc; - }, {}); - - // Expanded view: Get hardware parts grouped by component (for expanded view) - const getExpandedHardwareParts = () => { - const printedParts = getRequiredPrintedParts(); - const printedPartIds = new Set(printedParts.map(p => p.id)); - const expandedHardware = []; - const selectedOptionIds = new Set(); - - 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; - 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; - 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, - }); - } - }); - - if (componentHardware.length > 0) { - expandedHardware.push({ - component: component.category || componentKey, - parts: componentHardware, - }); - } - }); - - return expandedHardware; - }; - - const expandedHardwareByComponent = getExpandedHardwareParts(); - - // 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); - }; + // Calculate all required data + const total = calculateTotal(config); + const printedParts = getRequiredPrintedParts(config); + const hardwareParts = getRequiredHardwareParts(config); + const filamentTotals = getTotalFilamentEstimate(printedParts); + const totalTime = getTotalTimeEstimate(printedParts); + const expandedHardwareByComponent = getExpandedHardwareParts(config); // Define tabs const tabs = [ @@ -743,874 +45,54 @@ export default function BOMSummary({ config }) {

{/* Tab Navigation */} -
- -
+ {/* Tab Content */}
- {/* Overview Tab */} {activeTab === 'overview' && ( -
- {/* Hardware (Motor & Power Supply) */} - {(config.motor || config.powerSupply) && ( -
-

Hardware

-
- {config.motor && ( -
- {config.motor.image && ( - {config.motor.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.motor.name} - {formatPrice(config.motor.price)} -
- )} - {config.powerSupply && ( -
- {config.powerSupply.image && ( - {config.powerSupply.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.powerSupply.name} - {formatPrice(config.powerSupply.price)} -
- )} -
-
- )} - - {/* Filament Usage */} - {(filamentTotals.total > 0 || totalTime !== '0m') && ( -
-

Filament Usage

-
- {filamentTotals.total > 0 && ( -
-
- Total Filament: - {Math.round(filamentTotals.total)}g -
- {filamentTotals.primary > 0 && ( -
-
-
- Primary ({getColorName(config.primaryColor, 'primary')}): -
- {Math.round(filamentTotals.primary)}g -
- )} - {filamentTotals.secondary > 0 && ( -
-
-
- Secondary ({getColorName(config.accentColor, 'accent')}): -
- {Math.round(filamentTotals.secondary)}g -
- )} -
- )} - {totalTime !== '0m' && ( -
- Total Printing Time: - {totalTime} -
- )} -
-
- )} - - {/* Selected Options/Kit */} - {(config.mount || config.cover || config.pcbMount || config.standHinge || config.standFeet || - (config.standCrossbarSupports && config.standCrossbarSupports.length > 0) || - (config.remoteType || config.remote?.id)) && ( -
-

Selected Options

-
- {config.mount && ( -
- {config.mount.image && ( - {config.mount.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.mount.name} -
- )} - {config.cover && ( -
- {config.cover.image && ( - {config.cover.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.cover.name} -
- )} - {config.pcbMount && ( -
- {config.pcbMount.image && ( - {config.pcbMount.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.pcbMount.name} -
- )} - {config.standHinge && ( -
- {config.standHinge.image && ( - {config.standHinge.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.standHinge.name} -
- )} - {config.standFeet && ( -
- {config.standFeet.image && ( - {config.standFeet.name} { - e.target.style.display = 'none'; - }} - /> - )} - {config.standFeet.name} -
- )} - {config.standCrossbarSupports && config.standCrossbarSupports.length > 0 && ( - <> - {config.standCrossbarSupports.map((support) => ( -
- {support.image && ( - {support.name} { - e.target.style.display = 'none'; - }} - /> - )} - {support.name} -
- ))} - - )} - {(config.remoteType || config.remote?.id) && (() => { - const remoteId = config.remoteType || config.remote?.id; - const remoteSystem = partsData.components?.remotes?.systems?.[remoteId]; - return remoteSystem ? ( -
- {remoteSystem.image && ( - {remoteSystem.name} { - e.target.style.display = 'none'; - }} - /> - )} - {remoteSystem.name} -
- ) : null; - })()} -
-
- )} - - {/* Toy Mounts */} - {config.toyMountOptions && config.toyMountOptions.length > 0 && ( -
-

Toy Mounts

-
- {config.toyMountOptions.map((toyMount) => ( -
- {toyMount.image && ( - {toyMount.name} { - e.target.style.display = 'none'; - }} - /> - )} - {toyMount.name} - {toyMount.description && ( - {toyMount.description} - )} -
- ))} -
-
- )} - - {/* Total */} -
-
-

Total Hardware Cost

-

${total.toFixed(2)}

-
-
-
+ )} - {/* Printed Parts Tab */} {activeTab === 'printed' && ( -
- {printedParts.length > 0 ? ( - <> -
-

Required Printed Parts

-
-
- {Object.entries(mainSections).map(([mainSectionName, subcategories]) => { - if (!sectionHasParts(subcategories)) { - return null; - } - - return ( -
-

{mainSectionName}

-
- {subcategories.map((category) => { - const parts = partsByCategory[category]; - if (!parts || parts.length === 0) { - return null; - } - - return ( -
-
-
{category}
- {parts.some(p => p.replacesActuatorMiddle) && ( - - Replaces standard ossm-actuator-body-middle - - )} -
-
- - - - - - - - - - - - - {parts.map((part) => { - const partColour = part.colour || 'primary'; - const colorHex = getColorHex( - partColour === 'primary' ? config.primaryColor : config.accentColor, - partColour - ); - const colorName = getColorName( - partColour === 'primary' ? config.primaryColor : config.accentColor, - partColour - ); - - return ( - - - - - - - - - ); - })} - -
Part NameColorDescriptionFile PathQuantityFilament
-

{part.name}

-
-
-
- {partColour} -
-
-

{part.description || '-'}

-
- {part.isHardwareOnly ? ( - Hardware only - ) : part.filePath ? ( -

{part.filePath}

- ) : ( - - - )} -
-

{part.quantity || 1}

-
- {part.isHardwareOnly ? ( - - - ) : part.filamentEstimate !== undefined && part.filamentEstimate > 0 ? ( -

- {typeof part.filamentEstimate === 'number' - ? (part.filamentEstimate * (part.quantity || 1)).toFixed(1) - : part.filamentEstimate}g - {(part.quantity || 1) > 1 && ( - - ({(typeof part.filamentEstimate === 'number' ? part.filamentEstimate : parseFloat(part.filamentEstimate.replace(/[~g]/g, '').trim()) || 0).toFixed(1)}g × {part.quantity}) - - )} -

- ) : ( - - - )} -
-
-
- ); - })} -
-
- ); - })} - - {/* 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 ( -
-
-

{category}

- {parts.some(p => p.replacesActuatorMiddle) && ( - - Replaces standard ossm-actuator-body-middle - - )} -
-
- - - - - - - - - - - - - {parts.map((part) => { - const partColour = part.colour || 'primary'; - const colorHex = getColorHex( - partColour === 'primary' ? config.primaryColor : config.accentColor, - partColour - ); - const colorName = getColorName( - partColour === 'primary' ? config.primaryColor : config.accentColor, - partColour - ); - - return ( - - - - - - - - - ); - })} - -
Part NameColorDescriptionFile PathQuantityFilament
-

{part.name}

-
-
-
- {partColour} -
-
-

{part.description || '-'}

-
- {part.isHardwareOnly ? ( - Hardware only - ) : part.filePath ? ( -

{part.filePath}

- ) : ( - - - )} -
-

{part.quantity || 1}

-
- {part.filamentEstimate !== undefined && part.filamentEstimate > 0 ? ( -

- {typeof part.filamentEstimate === 'number' - ? (part.filamentEstimate * (part.quantity || 1)).toFixed(1) - : part.filamentEstimate}g - {(part.quantity || 1) > 1 && ( - - ({(typeof part.filamentEstimate === 'number' ? part.filamentEstimate : parseFloat(part.filamentEstimate.replace(/[~g]/g, '').trim()) || 0).toFixed(1)}g × {part.quantity}) - - )} -

- ) : ( - - - )} -
-
-
- ); - })} - {(filamentTotals.total > 0 || totalTime !== '0m') && ( -
- {filamentTotals.total > 0 && ( -
-
- Total Filament Estimate: - {Math.round(filamentTotals.total)}g -
- {filamentTotals.primary > 0 && ( -
- Primary ({getColorName(config.primaryColor, 'primary')}): - {Math.round(filamentTotals.primary)}g -
- )} - {filamentTotals.secondary > 0 && ( -
- Secondary ({getColorName(config.accentColor, 'accent')}): - {Math.round(filamentTotals.secondary)}g -
- )} -
- )} - {totalTime !== '0m' && ( -
- Total Printing Time: - {totalTime} -
- )} -
- )} -
- - ) : ( -
-

No printed parts required for this configuration.

-
- )} -
+ )} - {/* Hardware Tab */} {activeTab === 'hardware' && ( -
- {hardwareParts.length > 0 ? ( - <> -
-
-

Required Hardware Parts

-
- - -
-
-
-
- {hardwareViewMode === 'unified' ? ( - // Unified view: Group by hardware type (Fasteners, Motion Components, etc.) - Object.entries(hardwareByType).sort(([a], [b]) => { - // Sort order: Fasteners, Motion Components, Aluminum Extrusion, Electronics, Other Hardware - 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; - }).map(([type, parts]) => ( -
-

{type}

-
- - - - - - - - - - - {parts.map((part) => ( - - - - - - - ))} - -
Part NameDescriptionQuantityPrice
-

{part.name}

-
-

{part.description || '-'}

-
-

{part.quantity || 1}

-
- {part.price && part.price > 0 ? ( -

{formatPrice(part.price)}

- ) : ( - - - )} -
-
-
- )) - ) : ( - // Expanded view: Group by component BOMs (shows hardware breakdown by component) - expandedHardwareByComponent.map(({ component, parts }) => ( -
-

{component}

-
- - - - - - - - - - - - {parts.map((part) => ( - - - - - - - - ))} - -
Part NameDescriptionTypeQuantityPrice
-

{part.name}

-
-

{part.description || '-'}

-
- - {part.hardwareType || 'Other Hardware'} - - -

{part.quantity || 1}

-
- {part.price && part.price > 0 ? ( -

{formatPrice(part.price)}

- ) : ( - - - )} -
-
-
- )) - )} -
- - ) : ( -
-

No hardware parts required for this configuration.

-
- )} -
+ )} {/* Export & Share Buttons - Show on all tabs */}
- {/* Share Button */} - - - {/* Export Button */} - + +
- - {/* Legacy export buttons (hidden but kept for backward compatibility) */}
Export includes: Overview (Markdown), BOM (Excel), Print List (Excel), and Print Files (organized by component/color)
@@ -1619,3 +101,22 @@ export default function BOMSummary({ config }) {
); } + +BOMSummary.propTypes = { + config: PropTypes.shape({ + motor: PropTypes.object, + powerSupply: PropTypes.object, + primaryColor: PropTypes.string, + accentColor: PropTypes.string, + mount: PropTypes.object, + cover: PropTypes.object, + standHinge: PropTypes.object, + standFeet: PropTypes.object, + standCrossbarSupports: PropTypes.array, + pcbMount: PropTypes.object, + remoteKnob: PropTypes.object, + remoteType: PropTypes.string, + remote: PropTypes.object, + toyMountOptions: PropTypes.array, + }).isRequired, +}; diff --git a/website/src/components/BOMSummary/HardwareTab.jsx b/website/src/components/BOMSummary/HardwareTab.jsx new file mode 100644 index 0000000..4b45493 --- /dev/null +++ b/website/src/components/BOMSummary/HardwareTab.jsx @@ -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 ( + + +

{part.name}

+ + +

{part.description || '-'}

+ + +

{part.quantity || 1}

+ + + {hasPrice ? ( + + ) : ( + - + )} + + + ); +}; + +/** + * 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 ( + + +

{part.name}

+ + +

{part.description || '-'}

+ + + + {part.hardwareType || 'Other Hardware'} + + + +

{part.quantity || 1}

+ + + {hasPrice ? ( + + ) : ( + - + )} + + + ); +}; + +/** + * 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 ( +
+

No hardware parts required for this configuration.

+
+ ); + } + + return ( +
+
+
+

Required Hardware Parts

+
+ + +
+
+
+ +
+ {hardwareViewMode === 'unified' ? ( + // Unified view: Group by hardware type + Object.entries(hardwareByType) + .sort(([a], [b]) => sortHardwareTypes(a, b)) + .map(([type, parts]) => ( +
+

{type}

+ +
+ )) + ) : ( + // Expanded view: Group by component BOMs + expandedHardwareByComponent.map(({ component, parts }) => ( +
+

{component}

+ +
+ )) + )} +
+
+ ); +} + +HardwareTab.propTypes = { + hardwareParts: PropTypes.array.isRequired, + expandedHardwareByComponent: PropTypes.array.isRequired, +}; diff --git a/website/src/components/BOMSummary/OverviewTab.jsx b/website/src/components/BOMSummary/OverviewTab.jsx new file mode 100644 index 0000000..c12d475 --- /dev/null +++ b/website/src/components/BOMSummary/OverviewTab.jsx @@ -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 ( +
+ {/* Hardware (Motor & Power Supply) */} + {(config.motor || config.powerSupply) && ( +
+

Hardware

+
+ {config.motor && ( +
+ + + {config.motor.name} + + {motorPrice && ( + + {motorPrice} + + )} +
+ )} + {config.powerSupply && ( +
+ + + {config.powerSupply.name} + + {psuPrice && ( + + {psuPrice} + + )} +
+ )} +
+
+ )} + + {/* Filament Usage */} + + + {/* Selected Options/Kit */} + {(config.mount || config.cover || config.pcbMount || config.standHinge || config.standFeet || + (config.standCrossbarSupports && config.standCrossbarSupports.length > 0) || + (config.remoteType || config.remote?.id)) && ( +
+

Selected Options

+
+ {config.mount && ( +
+ + + {config.mount.name} + +
+ )} + {config.cover && ( +
+ + + {config.cover.name} + +
+ )} + {config.pcbMount && ( +
+ + + {config.pcbMount.name} + +
+ )} + {config.standHinge && ( +
+ + + {config.standHinge.name} + +
+ )} + {config.standFeet && ( +
+ + + {config.standFeet.name} + +
+ )} + {config.standCrossbarSupports && config.standCrossbarSupports.length > 0 && ( + <> + {config.standCrossbarSupports.map((support) => ( +
+ + + {support.name} + +
+ ))} + + )} + {(config.remoteType || config.remote?.id) && (() => { + const remoteId = config.remoteType || config.remote?.id; + const remoteSystem = partsData.components?.remotes?.systems?.[remoteId]; + return remoteSystem ? ( +
+ + + {remoteSystem.name} + +
+ ) : null; + })()} +
+
+ )} + + {/* Toy Mounts */} + {config.toyMountOptions && config.toyMountOptions.length > 0 && ( +
+

Toy Mounts

+
+ {config.toyMountOptions.map((toyMount) => ( +
+ + + {toyMount.name} + + {toyMount.description && ( + + {toyMount.description} + + )} +
+ ))} +
+
+ )} + + {/* Total */} +
+
+

Total Hardware Cost

+

+ {totalPrice || '...'} +

+
+
+
+ ); +} + +OverviewTab.propTypes = { + config: PropTypes.object.isRequired, + filamentTotals: PropTypes.object.isRequired, + totalTime: PropTypes.string.isRequired, + total: PropTypes.number.isRequired, + getColorName: PropTypes.func, + getColorHex: PropTypes.func, +}; diff --git a/website/src/components/BOMSummary/PrintedPartsTab.jsx b/website/src/components/BOMSummary/PrintedPartsTab.jsx new file mode 100644 index 0000000..0f69c6d --- /dev/null +++ b/website/src/components/BOMSummary/PrintedPartsTab.jsx @@ -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 ( + + +

{part.name}

+ + +
+
+ {partColour} +
+ + +

{part.description || '-'}

+ + + {part.isHardwareOnly ? ( + Hardware only + ) : part.filePath ? ( +

{part.filePath}

+ ) : ( + - + )} + + +

{part.quantity || 1}

+ + + {part.isHardwareOnly ? ( + - + ) : filamentData ? ( +

+ {filamentData.total} + {filamentData.perUnit && ( + + {filamentData.perUnit} + + )} +

+ ) : ( + - + )} + + + ); +}; + +/** + * 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 ( +
+

No printed parts required for this configuration.

+
+ ); + } + + return ( +
+
+

Required Printed Parts

+
+ +
+ {Object.entries(mainSections).map(([mainSectionName, subcategories]) => { + if (!sectionHasParts(subcategories)) { + return null; + } + + return ( +
+

{mainSectionName}

+
+ {subcategories.map((category) => { + const parts = partsByCategory[category]; + if (!parts || parts.length === 0) { + return null; + } + + return ( +
+
+
{category}
+ {parts.some(p => p.replacesActuatorMiddle) && ( + + Replaces standard ossm-actuator-body-middle + + )} +
+ renderPrintedPartRow(part, config, getColorName, getColorHex)} + /> +
+ ); + })} +
+
+ ); + })} + + {/* 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 ( +
+
+

{category}

+ {parts.some(p => p.replacesActuatorMiddle) && ( + + Replaces standard ossm-actuator-body-middle + + )} +
+ renderPrintedPartRow(part, config, getColorName, getColorHex)} + /> +
+ ); + })} + + +
+
+ ); +} + +PrintedPartsTab.propTypes = { + printedParts: PropTypes.array.isRequired, + config: PropTypes.object.isRequired, + filamentTotals: PropTypes.object.isRequired, + totalTime: PropTypes.string.isRequired, +}; diff --git a/website/src/components/BOMSummary/ShareButton.jsx b/website/src/components/BOMSummary/ShareButton.jsx new file mode 100644 index 0000000..6be3a69 --- /dev/null +++ b/website/src/components/BOMSummary/ShareButton.jsx @@ -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 ( + + ); +} + +ShareButton.propTypes = { + config: PropTypes.object.isRequired, +}; diff --git a/website/src/components/CurrencySwitcher.jsx b/website/src/components/CurrencySwitcher.jsx new file mode 100644 index 0000000..7522b74 --- /dev/null +++ b/website/src/components/CurrencySwitcher.jsx @@ -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 ( +
+
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+ {currencies.map((curr) => ( + + ))} +
+
+ + )} +
+
+ ); +} diff --git a/website/src/components/ThemeToggle.jsx b/website/src/components/ThemeToggle.jsx index 5ee68c3..d3cea92 100644 --- a/website/src/components/ThemeToggle.jsx +++ b/website/src/components/ThemeToggle.jsx @@ -6,7 +6,7 @@ export default function ThemeToggle() { return ( ); @@ -129,16 +172,16 @@ export default function MotorStep({ config, updateConfig }) { {/* Recommended Motor(s) */} {recommendedMotors.length > 0 && ( -
+
{hasSingleRecommended ? (
- {renderMotorCard(recommendedMotors[0], true, true)} + {renderMotorCard(recommendedMotors[0], true)}
) : (

Recommended Options

- {recommendedMotors.map((motor) => renderMotorCard(motor, true, false))} + {recommendedMotors.map((motor) => renderMotorCard(motor, false))}
)} @@ -150,10 +193,19 @@ export default function MotorStep({ config, updateConfig }) {

Other Options

- {otherMotors.map((motor) => renderMotorCard(motor, false, false))} + {otherMotors.map((motor) => renderMotorCard(motor, false))}
)}
); } + +MotorStep.propTypes = { + config: PropTypes.shape({ + motor: PropTypes.shape({ + id: PropTypes.string, + }), + }).isRequired, + updateConfig: PropTypes.func.isRequired, +}; diff --git a/website/src/components/steps/OptionsStep.jsx b/website/src/components/steps/OptionsStep.jsx index 44abad8..21db023 100644 --- a/website/src/components/steps/OptionsStep.jsx +++ b/website/src/components/steps/OptionsStep.jsx @@ -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 && ( -
- {option.name} { - e.target.style.display = 'none'; - }} - /> -
- )} +

diff --git a/website/src/components/steps/PowerSupplyStep.jsx b/website/src/components/steps/PowerSupplyStep.jsx index 6d03b7b..be7e5f9 100644 --- a/website/src/components/steps/PowerSupplyStep.jsx +++ b/website/src/components/steps/PowerSupplyStep.jsx @@ -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 (

Select Power Supply

@@ -38,24 +59,17 @@ export default function PowerSupplyStep({ config, updateConfig }) { ))} diff --git a/website/src/components/steps/RemoteStep.jsx b/website/src/components/steps/RemoteStep.jsx index 99b960e..4ff35f3 100644 --- a/website/src/components/steps/RemoteStep.jsx +++ b/website/src/components/steps/RemoteStep.jsx @@ -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 && ( -
- {remote.name} { - e.target.style.display = 'none'; - }} - /> -
- )} +

diff --git a/website/src/components/steps/ToyMountStep.jsx b/website/src/components/steps/ToyMountStep.jsx index 6814bbd..c2bffda 100644 --- a/website/src/components/steps/ToyMountStep.jsx +++ b/website/src/components/steps/ToyMountStep.jsx @@ -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 }) { }`} >
- {option.image && ( -
- {option.name} { - e.target.style.display = 'none'; - }} - /> -
- )} +
+ +
diff --git a/website/src/components/ui/AsyncPrice.jsx b/website/src/components/ui/AsyncPrice.jsx new file mode 100644 index 0000000..ced2ad2 --- /dev/null +++ b/website/src/components/ui/AsyncPrice.jsx @@ -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 {formattedPrice}; +} + +AsyncPrice.propTypes = { + price: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, + PropTypes.object, + ]), + className: PropTypes.string, + fallback: PropTypes.string, +}; diff --git a/website/src/components/ui/DataTable.jsx b/website/src/components/ui/DataTable.jsx new file mode 100644 index 0000000..3d9dd40 --- /dev/null +++ b/website/src/components/ui/DataTable.jsx @@ -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 ( +
+

{emptyMessage}

+
+ ); + } + + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {data.map((row, index) => renderRow(row, index))} + +
+ {column.label} +
+
+ ); +} + +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, +}; diff --git a/website/src/components/ui/ExportButton.jsx b/website/src/components/ui/ExportButton.jsx new file mode 100644 index 0000000..e24f676 --- /dev/null +++ b/website/src/components/ui/ExportButton.jsx @@ -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 ( + + ); +} + +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, +}; diff --git a/website/src/components/ui/FilamentDisplay.jsx b/website/src/components/ui/FilamentDisplay.jsx new file mode 100644 index 0000000..bf79267 --- /dev/null +++ b/website/src/components/ui/FilamentDisplay.jsx @@ -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 ( +
+

Filament Usage

+
+ {filamentTotals.total > 0 && ( +
+
+ Total Filament: + {Math.round(filamentTotals.total)}g +
+ {filamentTotals.primary > 0 && getColorName && getColorHex && ( +
+
+
+ Primary ({getColorName(primaryColor, 'primary')}): +
+ {Math.round(filamentTotals.primary)}g +
+ )} + {filamentTotals.secondary > 0 && getColorName && getColorHex && ( +
+
+
+ Secondary ({getColorName(accentColor, 'accent')}): +
+ {Math.round(filamentTotals.secondary)}g +
+ )} +
+ )} + {totalTime !== '0m' && ( +
+ Total Printing Time: + {totalTime} +
+ )} +
+
+ ); +} + +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, +}; diff --git a/website/src/components/ui/ImageWithFallback.jsx b/website/src/components/ui/ImageWithFallback.jsx new file mode 100644 index 0000000..ddfd7c1 --- /dev/null +++ b/website/src/components/ui/ImageWithFallback.jsx @@ -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 ( +
+ {alt} +
+ ); +} + +ImageWithFallback.propTypes = { + src: PropTypes.string, + alt: PropTypes.string.isRequired, + className: PropTypes.string, + containerClassName: PropTypes.string, + onError: PropTypes.func, +}; diff --git a/website/src/components/ui/OptionCard.jsx b/website/src/components/ui/OptionCard.jsx new file mode 100644 index 0000000..3769aa2 --- /dev/null +++ b/website/src/components/ui/OptionCard.jsx @@ -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 ( + + ); +} + +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, +}; diff --git a/website/src/components/ui/PriceDisplay.jsx b/website/src/components/ui/PriceDisplay.jsx new file mode 100644 index 0000000..000e678 --- /dev/null +++ b/website/src/components/ui/PriceDisplay.jsx @@ -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 {formattedPrice}; +} + +PriceDisplay.propTypes = { + price: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, + PropTypes.object, + ]), + className: PropTypes.string, +}; diff --git a/website/src/components/ui/TabNavigation.jsx b/website/src/components/ui/TabNavigation.jsx new file mode 100644 index 0000000..e6652cf --- /dev/null +++ b/website/src/components/ui/TabNavigation.jsx @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; + +/** + * Reusable tab navigation component + */ +export default function TabNavigation({ tabs, activeTab, onTabChange, className = '' }) { + return ( +
+ +
+ ); +} + +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, +}; diff --git a/website/src/components/ui/index.js b/website/src/components/ui/index.js new file mode 100644 index 0000000..3fe1fe7 --- /dev/null +++ b/website/src/components/ui/index.js @@ -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'; diff --git a/website/src/contexts/CurrencyContext.jsx b/website/src/contexts/CurrencyContext.jsx new file mode 100644 index 0000000..c09f4e4 --- /dev/null +++ b/website/src/contexts/CurrencyContext.jsx @@ -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 ( + + {children} + + ); +}; diff --git a/website/src/data/common/hardware.json b/website/src/data/common/hardware.json index bc8e5de..26f4876 100644 --- a/website/src/data/common/hardware.json +++ b/website/src/data/common/hardware.json @@ -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" + } } } } \ No newline at end of file diff --git a/website/src/data/components/actuator.json b/website/src/data/components/actuator.json index 90e531f..2ee77aa 100644 --- a/website/src/data/components/actuator.json +++ b/website/src/data/components/actuator.json @@ -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, diff --git a/website/src/data/components/motors.json b/website/src/data/components/motors.json index 25ee8d0..d7a963d 100644 --- a/website/src/data/components/motors.json +++ b/website/src/data/components/motors.json @@ -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" + } + ] } ] \ No newline at end of file diff --git a/website/src/data/components/pcb.json b/website/src/data/components/pcb.json new file mode 100644 index 0000000..11a5dbb --- /dev/null +++ b/website/src/data/components/pcb.json @@ -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" + } + ] + } +] diff --git a/website/src/data/components/powerSupplies.json b/website/src/data/components/powerSupplies.json index 40d7a08..830c0bf 100644 --- a/website/src/data/components/powerSupplies.json +++ b/website/src/data/components/powerSupplies.json @@ -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" } ] } diff --git a/website/src/data/components/stand.json b/website/src/data/components/stand.json index 845fc6c..648308e 100644 --- a/website/src/data/components/stand.json +++ b/website/src/data/components/stand.json @@ -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 } diff --git a/website/src/data/index.js b/website/src/data/index.js index 5e7631f..664cd1e 100644 --- a/website/src/data/index.js +++ b/website/src/data/index.js @@ -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, diff --git a/website/src/hooks/usePriceFormat.js b/website/src/hooks/usePriceFormat.js new file mode 100644 index 0000000..9cdef7a --- /dev/null +++ b/website/src/hooks/usePriceFormat.js @@ -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 }; +} diff --git a/website/src/main.jsx b/website/src/main.jsx index 0aec8f2..26f0132 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -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( - + + + , ) diff --git a/website/src/utils/bomUtils.js b/website/src/utils/bomUtils.js new file mode 100644 index 0000000..927d2af --- /dev/null +++ b/website/src/utils/bomUtils.js @@ -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; +}; diff --git a/website/src/utils/currencyService.js b/website/src/utils/currencyService.js new file mode 100644 index 0000000..e0e442a --- /dev/null +++ b/website/src/utils/currencyService.js @@ -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} - 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} - 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); + } +}; diff --git a/website/src/utils/exportUtils.js b/website/src/utils/exportUtils.js index 1e28dcf..2d84627 100644 --- a/website/src/utils/exportUtils.js +++ b/website/src/utils/exportUtils.js @@ -1,4 +1,5 @@ import ExcelJS from 'exceljs'; +import { extractNumericPrice, formatPrice } from './priceFormat.js'; // Generate markdown overview export const generateMarkdownOverview = (config, printedParts, hardwareParts, filamentTotals, totalTime, total) => { @@ -7,10 +8,24 @@ export const generateMarkdownOverview = (config, printedParts, hardwareParts, fi md.push('# OSSM Build Configuration'); md.push(`\n**Generated:** ${new Date().toLocaleString()}\n`); + // Helper function to get price display from links or fallback + const getPriceDisplay = (item) => { + if (!item) return 'N/A'; + if (item.links && item.links.length > 0) { + const prices = item.links.map(link => extractNumericPrice(link.price)).filter(p => p != null && p > 0); + if (prices.length === 0) return 'N/A'; + if (prices.length === 1) return formatPrice(prices[0]); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + return minPrice === maxPrice ? formatPrice(minPrice) : `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`; + } + return item.price ? formatPrice(item.price) : 'N/A'; + }; + // Motor if (config.motor) { md.push(`## Motor: ${config.motor.name}`); - md.push(`- **Price:** ${config.motor.price}`); + md.push(`- **Price:** ${getPriceDisplay(config.motor)}`); md.push(`- **Speed:** ${config.motor.speed}`); md.push(`- **Wattage:** ${config.motor.wattage}`); md.push(''); @@ -19,7 +34,7 @@ export const generateMarkdownOverview = (config, printedParts, hardwareParts, fi // Power Supply if (config.powerSupply) { md.push(`## Power Supply: ${config.powerSupply.name}`); - md.push(`- **Price:** ${config.powerSupply.price}`); + md.push(`- **Price:** ${getPriceDisplay(config.powerSupply)}`); md.push(''); } @@ -124,49 +139,83 @@ export const generateExcelBOM = (hardwareParts, printedParts, config) => { // Header rows.push(['Item', 'Name', 'Quantity', 'Price', 'Link', 'Category', 'Type']); - // Add motor + // Helper function to format price for Excel (handles new price structure and legacy formats) + const formatPriceForExcel = (price) => { + if (price == null) return ''; + return formatPrice(price); + }; + + // Add motor - one row per link option with its price if (config.motor) { const motorLinks = config.motor.links || []; - const firstLink = motorLinks.length > 0 ? motorLinks[0].link : ''; - rows.push([ - 'Motor', - config.motor.name, - 1, - config.motor.price, - firstLink, - 'Motor', - 'Hardware' - ]); + if (motorLinks.length > 0) { + motorLinks.forEach(link => { + rows.push([ + 'Motor', + `${config.motor.name} - ${link.store}`, + 1, + formatPriceForExcel(link.price), + link.link || '', + 'Motor', + 'Hardware' + ]); + }); + } else { + // Fallback if no links + rows.push([ + 'Motor', + config.motor.name, + 1, + config.motor.price || '', + '', + 'Motor', + 'Hardware' + ]); + } } - // Add power supply + // Add power supply - one row per link option with its price if (config.powerSupply) { const psuLinks = config.powerSupply.links || []; - const firstLink = psuLinks.length > 0 ? psuLinks[0].link : ''; - rows.push([ - 'Power Supply', - config.powerSupply.name, - 1, - config.powerSupply.price, - firstLink, - 'Power Supply', - 'Hardware' - ]); + if (psuLinks.length > 0) { + psuLinks.forEach(link => { + rows.push([ + 'Power Supply', + `${config.powerSupply.name} - ${link.store}`, + 1, + formatPriceForExcel(link.price), + link.link || '', + 'Power Supply', + 'Hardware' + ]); + }); + } else { + // Fallback if no links + rows.push([ + 'Power Supply', + config.powerSupply.name, + 1, + config.powerSupply.price || '', + '', + 'Power Supply', + 'Hardware' + ]); + } } // Add hardware parts hardwareParts.forEach(hw => { const links = hw.links || []; const firstLink = links.length > 0 ? links[0].link : (hw.url || ''); - rows.push([ - hw.id || '', - hw.name || '', - hw.quantity || 1, - hw.price ? `$${parseFloat(hw.price).toFixed(2)}` : '', - firstLink, - hw.category || 'Hardware', - 'Hardware' - ]); + rows.push([ + hw.id || '', + hw.name || '', + hw.quantity || 1, + hw.price ? formatPrice(hw.price) : '', + firstLink, + hw.category || 'Hardware', + 'Hardware' + ]); }); // Add printed parts (for reference, not purchase) diff --git a/website/src/utils/partUtils.js b/website/src/utils/partUtils.js new file mode 100644 index 0000000..b83d40e --- /dev/null +++ b/website/src/utils/partUtils.js @@ -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; +}; diff --git a/website/src/utils/priceFormat.js b/website/src/utils/priceFormat.js index ea79923..33366ba 100644 --- a/website/src/utils/priceFormat.js +++ b/website/src/utils/priceFormat.js @@ -1,26 +1,114 @@ -// Helper function to format price (handles both number and string prices) -export function formatPrice(price) { +import { convertPrice } from './currencyService'; + +// Helper function to get currency symbol +export function getCurrencySymbol(currency) { + const symbols = { + 'USD': '$', + 'EUR': '€', + 'GBP': '£', + 'CAD': 'C$', + 'AUD': 'A$', + 'JPY': '¥', + 'CNY': '¥', + }; + return symbols[currency?.toUpperCase()] || currency?.toUpperCase() || 'C$'; +} + +// Helper function to format price (handles new price object structure and legacy formats) +// Note: This is the synchronous version. For currency conversion, use the async version or hook +export function formatPrice(price, displayCurrency = 'CAD') { + // Handle new price structure with currency + if (price && typeof price === 'object' && 'amount' in price) { + const { amount, currency = displayCurrency } = price; + // Use the currency from the price object, or fall back to displayCurrency + const finalCurrency = currency || displayCurrency; + + if (typeof amount === 'object' && amount.min !== undefined && amount.max !== undefined) { + // Price range + const currencySymbol = getCurrencySymbol(finalCurrency); + if (amount.min === amount.max) { + return `${currencySymbol}${amount.min.toFixed(2)}`; + } + return `${currencySymbol}${amount.min.toFixed(2)} - ${currencySymbol}${amount.max.toFixed(2)}`; + } + + if (typeof amount === 'number') { + // Single price + const currencySymbol = getCurrencySymbol(finalCurrency); + return `${currencySymbol}${amount.toFixed(2)}`; + } + } + + // Legacy format support for backward compatibility + // Use displayCurrency for legacy formats if (typeof price === 'number') { - return `$${price.toFixed(2)}`; + const currencySymbol = getCurrencySymbol(displayCurrency); + return `${currencySymbol}${price.toFixed(2)}`; } if (typeof price === 'string') { // If it's already formatted as a string (e.g., "$125-$250"), return as-is - return price.startsWith('$') ? price : `$${price}`; + // But try to convert symbol if it's just a dollar sign and displayCurrency is different + if (price.startsWith('$') && displayCurrency !== 'USD') { + const currencySymbol = getCurrencySymbol(displayCurrency); + return price.replace('$', currencySymbol); + } + return price.startsWith('$') || price.match(/^[€£¥C$A$]/) ? price : `${getCurrencySymbol(displayCurrency)}${price}`; } - return '$0.00'; + return `${getCurrencySymbol(displayCurrency)}0.00`; } -// Helper function to get numeric price for calculations (returns 0 for string prices) +/** + * Async version that converts currency before formatting + */ +export async function formatPriceWithConversion(price, targetCurrency, exchangeRates = null) { + if (!price) return formatPrice(price, targetCurrency); + + try { + const converted = await convertPrice(price, targetCurrency, exchangeRates); + return formatPrice(converted, targetCurrency); + } catch (error) { + console.warn('Failed to convert price, formatting without conversion:', error); + return formatPrice(price, targetCurrency); + } +} + +// Helper function to get numeric price for calculations export function getNumericPrice(price) { + // Handle new price structure with currency + if (price && typeof price === 'object' && 'amount' in price) { + const { amount } = price; + + if (typeof amount === 'object' && amount.min !== undefined) { + // For ranges, return the minimum + return amount.min || 0; + } + + if (typeof amount === 'number') { + return amount; + } + } + + // Legacy format support if (typeof price === 'number') { return price; } if (typeof price === 'string') { - // Try to extract a number from string prices like "$125-$250" - const match = price.match(/\d+/); + // Try to extract a number from string prices like "$125-$250" or "206.96-234.00" + if (price.includes('-')) { + const range = price.split('-').map(p => parseFloat(p.trim().replace(/[^0-9.]/g, ''))).filter(p => !isNaN(p)); + return range.length > 0 ? Math.min(...range) : 0; + } + const match = price.match(/[\d.]+/); if (match) { return parseFloat(match[0]); } } return 0; } + +// Helper function to extract numeric price (handles both new and legacy formats) +export function extractNumericPrice(price) { + if (price == null) return null; + const numeric = getNumericPrice(price); + return numeric != null && !isNaN(numeric) ? numeric : null; +}