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:
MunchDev-oss
2026-01-10 03:04:28 -05:00
parent 86f0acc26b
commit aba0964a59
40 changed files with 3519 additions and 1843 deletions

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