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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {columns.map((column) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {data.map((row, index) => renderRow(row, index))}
+
+
+
+ );
+}
+
+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 (
+
+

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