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