svarog-ui-core
Version:
Core components of Svarog UI without themes
584 lines (510 loc) • 16.4 kB
JavaScript
// src/components/ProductDetail/ProductDetail.js
import {
createElement,
validateProps,
createComponent,
} from '../../utils/componentFactory.js';
import { createBaseComponent } from '../../utils/baseComponent.js';
import { withThemeAwareness } from '../../utils/composition.js';
// CSS injection imports
import { createStyleInjector } from '../../utils/styleInjection.js';
import { productDetailStyles } from './ProductDetail.styles.js';
// Component dependencies
import ImageSlider from '../ImageSlider/index.js';
import Typography from '../Typography/index.js';
import PriceDisplay from '../PriceDisplay/index.js';
import Button from '../Button/index.js';
import Tag from '../Tag/index.js';
import Grid from '../Grid/index.js';
// Create style injector for ProductDetail component
const injectProductDetailStyles = createStyleInjector('ProductDetail');
/**
* Migrates legacy props to standardized props
* @private
* @param {Object} props - Component props
* @returns {Object} Migrated props
*/
const migrateLegacyProps = (props) => {
const migrated = { ...props };
// Handle legacy onAddToCart vs onClick
if ('onAddToCart' in props && !('onClick' in props)) {
console.warn(
'[ProductDetail] onAddToCart is deprecated, use onClick instead'
);
migrated.onClick = props.onAddToCart;
delete migrated.onAddToCart;
}
// Handle legacy description vs content
if ('description' in props && !('content' in props)) {
migrated.content = props.description;
}
return migrated;
};
/**
* Creates the product gallery section
* @private
* @param {Array} images - Product images
* @param {Function} onImageChange - Image change callback
* @returns {Object} Gallery element and component reference
*/
const createGallerySection = (images, onImageChange) => {
if (!images || images.length === 0) {
return {
element: createElement('div', {
classes: ['product-detail__gallery', 'product-detail__gallery--empty'],
children: [
createElement('div', {
classes: ['product-detail__no-image'],
text: 'No images available',
}),
],
}),
component: null,
};
}
const galleryComponent = ImageSlider({
images,
showThumbnails: images.length > 1,
showDots: false,
showArrows: images.length > 1,
onChange: onImageChange,
className: 'product-detail__image-slider',
});
const galleryContainer = createElement('div', {
classes: ['product-detail__gallery'],
children: [galleryComponent.getElement()],
});
return {
element: galleryContainer,
component: galleryComponent,
};
};
/**
* Creates the product information section (title, tags, description only)
* @private
* @param {Object} productInfo - Product information
* @returns {HTMLElement} Info section element
*/
const createInfoSection = (productInfo) => {
const { title, content, category, tags = [] } = productInfo;
const infoContainer = createElement('div', {
classes: ['product-detail__info'],
});
// Category and tags section
if (category || tags.length > 0) {
const metaSection = createElement('div', {
classes: ['product-detail__meta'],
});
if (category) {
const categoryTag = Tag({
label: category,
variant: 'secondary',
size: 'sm',
className: 'product-detail__category',
});
metaSection.appendChild(categoryTag.getElement());
}
if (tags.length > 0) {
const tagsContainer = createElement('div', {
classes: ['product-detail__tags'],
});
tags.forEach((tag) => {
const tagComponent = Tag({
label: typeof tag === 'string' ? tag : tag.label,
variant:
typeof tag === 'object' ? tag.variant || 'default' : 'default',
size: 'sm',
onClick: typeof tag === 'object' ? tag.onClick : null,
});
tagsContainer.appendChild(tagComponent.getElement());
});
metaSection.appendChild(tagsContainer);
}
infoContainer.appendChild(metaSection);
}
// Title
const titleComponent = Typography({
children: title,
as: 'h1',
className: 'product-detail__title',
});
infoContainer.appendChild(titleComponent.getElement());
// Content/Description
if (content) {
const contentComponent = Typography({
children: content,
as: 'div',
className: 'product-detail__content',
});
infoContainer.appendChild(contentComponent.getElement());
}
return infoContainer;
};
/**
* Creates the specifications section separately for better layout control
* @private
* @param {Object} specifications - Product specifications
* @returns {HTMLElement|null} Specifications section element or null
*/
const createSpecificationsSection = (specifications) => {
if (!specifications || Object.keys(specifications).length === 0) {
return null;
}
const specsSection = createElement('div', {
classes: ['product-detail__specifications'],
});
const specsTitle = Typography({
children: 'Specifications',
as: 'h3',
className: 'product-detail__specs-title',
});
specsSection.appendChild(specsTitle.getElement());
const specsList = createElement('dl', {
classes: ['product-detail__specs-list'],
});
Object.entries(specifications).forEach(([key, value]) => {
const term = createElement('dt', {
classes: ['product-detail__spec-term'],
text: key,
});
const definition = createElement('dd', {
classes: ['product-detail__spec-definition'],
text: value,
});
specsList.appendChild(term);
specsList.appendChild(definition);
});
specsSection.appendChild(specsList);
return specsSection;
};
/**
* Creates the purchase section with price and actions
* @private
* @param {Object} purchaseInfo - Purchase information
* @returns {Object} Purchase section element and components
*/
const createPurchaseSection = (purchaseInfo) => {
const {
price,
currency = '€',
priceInfo,
loading = false,
priceHighlighted = false,
buttonText = 'Add to Cart',
onClick,
disabled = false,
additionalButtons = [],
} = purchaseInfo;
const purchaseContainer = createElement('div', {
classes: ['product-detail__purchase'],
});
// Price display
const priceComponent = PriceDisplay({
value: `${currency}${price}`,
loading,
isHighlighted: priceHighlighted,
className: 'product-detail__price',
});
purchaseContainer.appendChild(priceComponent.getElement());
// Price info
if (priceInfo) {
const priceInfoComponent = Typography({
children: priceInfo,
as: 'p',
className: 'product-detail__price-info',
});
purchaseContainer.appendChild(priceInfoComponent.getElement());
}
// Action buttons
const actionsContainer = createElement('div', {
classes: ['product-detail__actions'],
});
// Primary action button
const primaryButton = Button({
text: buttonText,
variant: 'primary',
size: 'lg',
onClick,
disabled: disabled || loading,
loading,
className: 'product-detail__primary-button',
});
actionsContainer.appendChild(primaryButton.getElement());
// Additional buttons
const additionalButtonComponents = [];
additionalButtons.forEach((buttonConfig) => {
const button = Button({
text: buttonConfig.text,
variant: buttonConfig.variant || 'secondary',
size: buttonConfig.size || 'lg',
onClick: buttonConfig.onClick,
disabled: buttonConfig.disabled || loading,
className: `product-detail__additional-button ${buttonConfig.className || ''}`,
});
actionsContainer.appendChild(button.getElement());
additionalButtonComponents.push(button);
});
purchaseContainer.appendChild(actionsContainer);
return {
element: purchaseContainer,
components: {
priceComponent,
primaryButton,
additionalButtons: additionalButtonComponents,
},
};
};
/**
* Creates a ProductDetail component
* @param {Object} props - ProductDetail properties
* @param {Array} props.images - Array of product images
* @param {string} props.title - Product title
* @param {string} [props.content] - Product description/content
* @param {string} [props.description] - DEPRECATED: Use content instead
* @param {string} [props.category] - Product category
* @param {Array} [props.tags] - Array of tags
* @param {Object} [props.specifications] - Product specifications
* @param {string|number} props.price - Product price
* @param {string} [props.currency='€'] - Currency symbol
* @param {string} [props.priceInfo] - Additional price information
* @param {boolean} [props.loading=false] - Loading state
* @param {boolean} [props.priceHighlighted=false] - Highlight price
* @param {string} [props.buttonText='Add to Cart'] - Primary button text
* @param {Function} [props.onClick] - Primary action callback
* @param {Function} [props.onAddToCart] - DEPRECATED: Use onClick instead
* @param {boolean} [props.disabled=false] - Disable actions
* @param {Array} [props.additionalButtons] - Additional action buttons
* @param {Function} [props.onImageChange] - Image change callback
* @param {string} [props.className] - Additional CSS classes
* @returns {Object} ProductDetail component API
*/
const createProductDetail = (props) => {
// Inject styles on first render
injectProductDetailStyles(productDetailStyles);
// Migrate legacy props
const normalizedProps = migrateLegacyProps(props);
// Validate required props
validateProps(
normalizedProps,
createProductDetail.requiredProps,
'ProductDetail'
);
// Extract and structure props
const {
images = [],
title,
content,
category,
tags = [],
specifications = {},
price,
currency = '€',
priceInfo,
loading = false,
priceHighlighted = false,
buttonText = 'Add to Cart',
onClick = () => {},
disabled = false,
additionalButtons = [],
onImageChange,
className = '',
} = normalizedProps;
// Component state
const state = { ...normalizedProps };
const components = {};
// Build main container classes
const containerClasses = [
'product-detail',
loading && 'product-detail--loading',
disabled && 'product-detail--disabled',
className,
].filter(Boolean);
/**
* Renders the complete product detail
* @returns {HTMLElement} Product detail element
*/
const renderProductDetail = () => {
const container = createElement('div', {
classes: containerClasses,
});
// Create Grid for responsive layout with responsive gap
const getResponsiveGap = () => {
if (typeof window !== 'undefined') {
const width = window.innerWidth;
if (width < 768) return '1rem'; // Mobile
if (width < 1024) return '1.5rem'; // Tablet
return '2rem'; // Desktop
}
return '2rem'; // Default for SSR
};
const mainGrid = Grid({
gap: getResponsiveGap(),
className: 'product-detail__grid',
});
// Gallery section (left on desktop, top on mobile)
const gallery = createGallerySection(images, onImageChange);
components.gallery = gallery.component;
const galleryColumn = Grid.Column({
width: 12,
tabletWidth: 6,
desktopWidth: 6,
children: gallery.element,
});
// Right column content (right on desktop, below image on mobile)
const rightColumnContent = createElement('div', {
classes: ['product-detail__right-column'],
});
// 1. Info section (title, tags, description)
const productInfo = { title, content, category, tags };
const infoElement = createInfoSection(productInfo);
rightColumnContent.appendChild(infoElement);
// 2. Purchase section (price and actions) - moved up for better UX
const purchaseInfo = {
price,
currency,
priceInfo,
loading,
priceHighlighted,
buttonText,
onClick,
disabled,
additionalButtons,
};
const purchase = createPurchaseSection(purchaseInfo);
components.purchase = purchase.components;
rightColumnContent.appendChild(purchase.element);
// 3. Specifications section (moved to bottom)
const specsElement = createSpecificationsSection(specifications);
if (specsElement) {
rightColumnContent.appendChild(specsElement);
}
const rightColumn = Grid.Column({
width: 12,
tabletWidth: 6,
desktopWidth: 6,
children: rightColumnContent,
});
// Add columns to grid
mainGrid.appendChild(galleryColumn.getElement());
mainGrid.appendChild(rightColumn.getElement());
// Add grid to container
container.appendChild(mainGrid.getElement());
return container;
};
// Create base component
const baseComponent = createBaseComponent(renderProductDetail)(state);
// Enhanced update method
const originalUpdate = baseComponent.update;
baseComponent.update = function (newProps) {
const migratedProps = migrateLegacyProps(newProps);
Object.assign(state, migratedProps);
return originalUpdate.call(this, migratedProps);
};
// Enhanced destroy method
const originalDestroy = baseComponent.destroy;
baseComponent.destroy = function () {
// Destroy child components
if (components.gallery) {
components.gallery.destroy();
}
if (components.purchase) {
Object.values(components.purchase).forEach((comp) => {
if (Array.isArray(comp)) {
comp.forEach((c) => c?.destroy?.());
} else {
comp?.destroy?.();
}
});
}
return originalDestroy.call(this);
};
return {
...baseComponent,
/**
* Update price information
* @param {string|number} newPrice - New price
* @param {boolean} [isHighlighted=false] - Highlight price
* @param {string} [newPriceInfo] - New price info
* @returns {Object} Component for chaining
*/
setPrice(newPrice, isHighlighted = false, newPriceInfo) {
if (components.purchase?.priceComponent) {
components.purchase.priceComponent.setValue(
`${state.currency}${newPrice}`,
isHighlighted
);
}
state.price = newPrice;
state.priceHighlighted = isHighlighted;
if (newPriceInfo !== undefined) {
state.priceInfo = newPriceInfo;
}
return this;
},
/**
* Set loading state for price and actions
* @param {boolean} isLoading - Loading state
* @returns {Object} Component for chaining
*/
setLoading(isLoading) {
if (components.purchase?.priceComponent) {
components.purchase.priceComponent.setLoading(isLoading);
}
if (components.purchase?.primaryButton) {
components.purchase.primaryButton.update({ loading: isLoading });
}
state.loading = isLoading;
return this;
},
/**
* Navigate to specific product image
* @param {number} index - Image index
* @returns {Object} Component for chaining
*/
goToImage(index) {
if (components.gallery) {
components.gallery.goToSlide(index);
}
return this;
},
/**
* Get current image index
* @returns {number} Current image index
*/
getCurrentImageIndex() {
return components.gallery ? components.gallery.getCurrentIndex() : 0;
},
/**
* Update product content
* @param {string} newContent - New content
* @returns {Object} Component for chaining
*/
setContent(newContent) {
return this.update({ content: newContent });
},
/**
* 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(
`ProductDetail: theme changed from ${previousTheme} to ${theme}`
);
},
};
};
// Define required props for validation
createProductDetail.requiredProps = ['title', 'price'];
// Create the component with theme awareness
const ProductDetail = withThemeAwareness(
createComponent('ProductDetail', createProductDetail)
);
export default ProductDetail;