UNPKG

svarog-ui-core

Version:

Core components of Svarog UI without themes

409 lines (357 loc) 11.4 kB
// src/components/ProductCard/ProductCard.js import { createElement, validateProps, createComponent, } from '../../utils/componentFactory.js'; import { withThemeAwareness } from '../../utils/composition.js'; import { createStyleInjector } from '../../utils/styleInjection.js'; import { productCardStyles } from './ProductCard.styles.js'; import Card from '../Card/Card.js'; import Button from '../Button/Button.js'; import Typography from '../Typography/Typography.js'; import Image from '../Image/Image.js'; import PriceDisplay from '../PriceDisplay/PriceDisplay.js'; // Create style injector for ProductCard component const injectProductCardStyles = createStyleInjector('ProductCard'); /** * Creates the product image element * @private * @param {string} imageUrl - Product image URL * @param {string} title - Product title for alt text * @param {string} [fallbackImageUrl] - Fallback image URL * @returns {HTMLElement} The image element */ const createProductImageElement = (imageUrl, title, fallbackImageUrl) => { const imageComponent = Image({ imageUrl, alt: title, fallbackImageUrl, className: 'product-card__image', responsive: true, }); return imageComponent.getElement(); }; /** * Creates the product specifications section * @private * @param {Object} productData - Product specifications * @returns {HTMLElement} The product data element */ const createProductDataElement = (productData) => { const container = createElement('div', { classes: 'product-card__data', }); const specsList = createElement('ul', { classes: 'product-card__specs', }); Object.entries(productData).forEach(([key, value]) => { const specItem = createElement('li', { classes: 'product-card__spec-item', }); const specLabel = Typography({ children: `${key}:`, className: 'product-card__spec-label', }).getElement(); const specValue = Typography({ children: value, className: 'product-card__spec-value', }).getElement(); specItem.appendChild(specLabel); specItem.appendChild(specValue); specsList.appendChild(specItem); }); container.appendChild(specsList); return container; }; /** * Creates the price and action section * @private * @param {string|number} price - Product price * @param {string} currency - Currency symbol * @param {string} buttonText - Text for the reserve button * @param {Function} onClick - Callback function when reserve button is clicked * @param {boolean} loading - Whether price is loading * @param {boolean} priceHighlighted - Whether price should be highlighted * @param {string} [priceInfo] - Additional price information (e.g., "inkl. Steuer") * @returns {Object} The actions element and component references */ const createActionsElement = ( price, currency, buttonText, onClick, loading, priceHighlighted, priceInfo ) => { const container = createElement('div', { classes: 'product-card__actions', }); const priceComponent = PriceDisplay({ label: '', // No label for product card prices value: `${currency}${price}`, loading, isHighlighted: priceHighlighted, className: 'product-card__price-display', }); const priceElement = priceComponent.getElement(); container.appendChild(priceElement); // Add price info if provided if (priceInfo) { const priceInfoElement = Typography({ children: priceInfo, className: 'product-card__price-info', variant: 'caption', }).getElement(); container.appendChild(priceInfoElement); } const reserveButton = Button({ text: buttonText, onClick, className: 'product-card__reserve-button', disabled: loading, }).getElement(); container.appendChild(reserveButton); return { element: container, priceComponent, }; }; /** * Migrates legacy props to standardized props * @private * @param {Object} props - Component props * @returns {Object} Migrated props */ const migrateLegacyProps = (props) => { const migrated = { ...props }; if ('onReserve' in props && !('onClick' in props)) { console.warn('[ProductCard] onReserve is deprecated, use onClick instead'); migrated.onClick = props.onReserve; delete migrated.onReserve; } return migrated; }; /** * Creates a ProductCard component * @param {Object} props - ProductCard properties * @param {string} props.imageUrl - URL to the product image * @param {string} [props.fallbackImageUrl] - Fallback image URL if primary fails * @param {string} props.title - Product title * @param {Object} props.productData - Product specifications * @param {string|number} props.price - Product price * @param {string} [props.currency='€'] - Currency symbol * @param {string} [props.buttonText='Reserve'] - Text for the reserve button * @param {Function} [props.onClick] - Callback function when reserve button is clicked * @param {Function} [props.onReserve] - DEPRECATED: Use onClick instead * @param {string} [props.className=''] - Additional CSS class names * @param {boolean} [props.loading=false] - Whether price is loading * @param {boolean} [props.priceHighlighted=false] - Whether price should be highlighted * @param {string} [props.priceInfo] - Additional price information (e.g., "inkl. Steuer") * @returns {Object} ProductCard component API */ const createProductCard = (props) => { // Inject styles on first render (automatically cached) injectProductCardStyles(productCardStyles); // Migrate legacy props const normalizedProps = migrateLegacyProps(props); // Validate required props validateProps( normalizedProps, createProductCard.requiredProps, 'ProductCard' ); // Extract props with defaults const { imageUrl, fallbackImageUrl, title, productData, price, currency = '€', buttonText = 'Reserve', onClick = () => {}, className = '', loading = false, priceHighlighted = false, priceInfo, } = normalizedProps; // Store mutable state const state = { ...normalizedProps }; const classNames = ['product-card']; if (className) { classNames.push(className); } // Create the product image using Image component const imageElement = createProductImageElement( imageUrl, title, fallbackImageUrl ); // Create content container for product data and actions const contentContainer = createElement('div', { classes: 'product-card__content-container', }); const productDataElement = createProductDataElement(productData); contentContainer.appendChild(productDataElement); const { element: actionsElement, priceComponent } = createActionsElement( price, currency, buttonText, onClick, loading, priceHighlighted, priceInfo ); contentContainer.appendChild(actionsElement); // Create the card using the base Card component without image const card = Card({ title, children: contentContainer, className: classNames.join(' '), }); // Get the card element and add the image const cardElement = card.getElement(); // Insert image before the title const titleElement = cardElement.querySelector('.card__title'); if (titleElement && titleElement.parentNode) { titleElement.parentNode.insertBefore(imageElement, titleElement); } const components = { card, priceComponent, }; // Helper function to update price info efficiently const updatePriceInfo = (newPriceInfo) => { const actionsContainer = cardElement.querySelector( '.product-card__actions' ); const existingPriceInfo = actionsContainer.querySelector( '.product-card__price-info' ); if (newPriceInfo) { if (existingPriceInfo) { // Update existing element existingPriceInfo.textContent = newPriceInfo; } else { // Create new element const newPriceInfoElement = Typography({ children: newPriceInfo, className: 'product-card__price-info', variant: 'caption', }).getElement(); // Insert after price display but before button const button = actionsContainer.querySelector( '.product-card__reserve-button' ); actionsContainer.insertBefore(newPriceInfoElement, button); } } else { // Remove existing element if empty string provided if (existingPriceInfo) { existingPriceInfo.remove(); } } // Update state state.priceInfo = newPriceInfo; }; return { /** * Gets the product card element * @returns {HTMLElement} The product card element */ getElement() { return cardElement; }, /** * Updates the product card with new props * @param {Object} newProps - New properties * @returns {Object} Current component (for chaining) */ update(newProps) { // Update state Object.assign(state, newProps); // Handle priceInfo update efficiently if ('priceInfo' in newProps) { updatePriceInfo(newProps.priceInfo); } // For other updates, we could implement more efficient partial updates // For now, this method maintains compatibility return this; }, /** * Clean up resources */ destroy() { if (components.card) { components.card.destroy(); } if (components.priceComponent) { components.priceComponent.destroy(); } }, /** * Set price loading state * @param {boolean} isLoading - Whether price is loading * @returns {Object} Component for chaining */ setPriceLoading(isLoading) { if (components.priceComponent) { components.priceComponent.setLoading(isLoading); } state.loading = isLoading; return this; }, /** * Update price value * @param {string|number} newPrice - New price value * @param {boolean} [isHighlighted=false] - Whether to highlight the price * @returns {Object} Component for chaining */ setPrice(newPrice, isHighlighted = false) { if (components.priceComponent) { components.priceComponent.setValue( `${state.currency}${newPrice}`, isHighlighted ); } state.price = newPrice; state.priceHighlighted = isHighlighted; return this; }, /** * Update price info text efficiently * @param {string} newPriceInfo - New price info text (empty string removes it) * @returns {Object} Component for chaining */ setPriceInfo(newPriceInfo) { updatePriceInfo(newPriceInfo); return this; }, /** * Get current component state * @returns {Object} Current state */ getState() { return { ...state }; }, /** * Handle theme changes * @param {string} theme - New theme * @param {string} previousTheme - Previous theme */ onThemeChange(theme, previousTheme) { console.debug( `ProductCard: theme changed from ${previousTheme} to ${theme}` ); }, }; }; // Define required props for validation createProductCard.requiredProps = ['imageUrl', 'title', 'productData', 'price']; // Create the component with theme awareness const ProductCard = withThemeAwareness( createComponent('ProductCard', createProductCard) ); export default ProductCard;