migration wip
This commit is contained in:
@@ -1,14 +1,39 @@
|
||||
/**
|
||||
* HTML Compatibility Layer
|
||||
* Handles HTML entities and formatting from WordPress exports
|
||||
* Handles HTML entities, formatting, and class conversions from WordPress exports
|
||||
*/
|
||||
|
||||
/**
|
||||
* Process HTML content from WordPress
|
||||
* - Sanitizes dangerous content
|
||||
* - Converts HTML entities
|
||||
* - Removes scripts and styles
|
||||
* - Processes shortcodes
|
||||
*/
|
||||
export function processHTML(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
|
||||
// Replace common HTML entities
|
||||
let processed = html;
|
||||
|
||||
// Step 1: Replace HTML entities
|
||||
processed = replaceHTMLEntities(processed);
|
||||
|
||||
// Step 2: Remove dangerous content
|
||||
processed = sanitizeHTML(processed);
|
||||
|
||||
// Step 3: Process WordPress shortcodes
|
||||
processed = processShortcodes(processed);
|
||||
|
||||
// Step 4: Clean up whitespace
|
||||
processed = cleanWhitespace(processed);
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace common HTML entities with their actual characters
|
||||
*/
|
||||
function replaceHTMLEntities(html: string): string {
|
||||
const entities: Record<string, string> = {
|
||||
'\u00A0': ' ', // Non-breaking space
|
||||
'&': '&',
|
||||
@@ -62,11 +87,20 @@ export function processHTML(html: string | null | undefined): string {
|
||||
'·': '·'
|
||||
};
|
||||
|
||||
// Replace entities
|
||||
let processed = html;
|
||||
for (const [entity, char] of Object.entries(entities)) {
|
||||
processed = processed.replace(new RegExp(entity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), char);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML by removing dangerous tags and attributes
|
||||
*/
|
||||
function sanitizeHTML(html: string): string {
|
||||
let processed = html;
|
||||
|
||||
// Remove script tags
|
||||
processed = processed.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
|
||||
@@ -78,30 +112,724 @@ export function processHTML(html: string | null | undefined): string {
|
||||
|
||||
// Remove dangerous attributes
|
||||
processed = processed.replace(/\s+(href|src)\s*=\s*["']\s*javascript:/gi, '');
|
||||
|
||||
|
||||
// Remove any remaining WordPress shortcode-like content (e.g., [vc_row...])
|
||||
processed = processed.replace(/\[[^\]]*\]/g, '');
|
||||
|
||||
// Keep HTML structure from processed data - allow divs with our classes
|
||||
// Allow: <p>, <br>, <h1-6>, <strong>, <b>, <em>, <i>, <ul>, <ol>, <li>, <a>, <div>, <span>, <img>
|
||||
// Also allow our vc-row/vc-column classes
|
||||
processed = processed.replace(/<\/?(?!\/?(p|br|h[1-6]|strong|b|em|i|ul|ol|li|a|div|span|img|small)(\s|>))[^>]*>/gi, '');
|
||||
// Allow safe HTML tags
|
||||
const allowedTags = [
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'b', 'em', 'i', 'u', 'small',
|
||||
'ul', 'ol', 'li',
|
||||
'a', 'div', 'span', 'img',
|
||||
'section', 'article', 'figure', 'figcaption',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'blockquote', 'code', 'pre',
|
||||
'hr'
|
||||
];
|
||||
|
||||
const tagPattern = allowedTags.join('|');
|
||||
processed = processed.replace(
|
||||
new RegExp(`<\/?(?!\\/?(?:${tagPattern})(\\s|>))[^>]*>`, 'gi'),
|
||||
''
|
||||
);
|
||||
|
||||
// Clean up empty paragraphs and extra spaces
|
||||
processed = processed.replace(/<p>\s*<\/p>/g, '');
|
||||
processed = processed.replace(/\s+/g, ' ').trim();
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process WordPress shortcodes by converting them to HTML with proper styling
|
||||
*/
|
||||
function processShortcodes(html: string): string {
|
||||
let processed = html;
|
||||
|
||||
// Process shortcode blocks first (most complex)
|
||||
processed = processVcRowShortcodes(processed);
|
||||
processed = processVcColumnShortcodes(processed);
|
||||
processed = processVcColumnTextShortcodes(processed);
|
||||
processed = processVcImageShortcodes(processed);
|
||||
processed = processVcButtonShortcodes(processed);
|
||||
processed = processVcSeparatorShortcodes(processed);
|
||||
processed = processVcVideoShortcodes(processed);
|
||||
processed = processBackgroundShortcodes(processed);
|
||||
|
||||
// Remove any remaining shortcodes
|
||||
processed = processed.replace(/\[[^\]]*\]/g, '');
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_row] shortcodes and convert to flex containers
|
||||
*/
|
||||
function processVcRowShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_row([^\]]*)\]([\s\S]*?)\[\/vc_row\]/g, (match, attrs, content) => {
|
||||
const classes = ['vc-row', 'flex', 'flex-wrap', '-mx-4'];
|
||||
|
||||
// Parse attributes for background colors, images, etc.
|
||||
const bgImage = extractAttribute(attrs, 'bg_image');
|
||||
const bgColor = extractAttribute(attrs, 'bg_color');
|
||||
const colorOverlay = extractAttribute(attrs, 'color_overlay');
|
||||
const overlayStrength = extractAttribute(attrs, 'overlay_strength');
|
||||
const enableGradient = extractAttribute(attrs, 'enable_gradient');
|
||||
const gradientDirection = extractAttribute(attrs, 'gradient_direction');
|
||||
const topPadding = extractAttribute(attrs, 'top_padding');
|
||||
const bottomPadding = extractAttribute(attrs, 'bottom_padding');
|
||||
const fullScreen = extractAttribute(attrs, 'full_screen_row_position');
|
||||
|
||||
// Build style string
|
||||
let style = '';
|
||||
let wrapperClasses = [...classes];
|
||||
|
||||
// Handle background image
|
||||
if (bgImage) {
|
||||
style += `background-image: url(/media/${bgImage}.webp); `;
|
||||
style += `background-size: cover; `;
|
||||
style += `background-position: center; `;
|
||||
wrapperClasses.push('bg-cover', 'bg-center');
|
||||
}
|
||||
|
||||
// Handle background color
|
||||
if (bgColor) {
|
||||
style += `background-color: ${bgColor}; `;
|
||||
}
|
||||
|
||||
// Handle color overlay
|
||||
if (colorOverlay) {
|
||||
const opacity = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
style += `position: relative; `;
|
||||
wrapperClasses.push('relative');
|
||||
|
||||
// Create overlay div
|
||||
const overlayStyle = `background-color: ${colorOverlay}; opacity: ${opacity};`;
|
||||
return `<div class="${wrapperClasses.join(' ')}" style="${style}">
|
||||
<div class="absolute inset-0" style="${overlayStyle}"></div>
|
||||
<div class="relative flex flex-wrap -mx-4 w-full">${content}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Handle gradient
|
||||
if (enableGradient === 'true' || enableGradient === '1') {
|
||||
const gradientClass = getGradientClass(gradientDirection);
|
||||
wrapperClasses.push(gradientClass);
|
||||
}
|
||||
|
||||
// Handle padding
|
||||
if (topPadding || bottomPadding) {
|
||||
const pt = topPadding ? `pt-[${topPadding}]` : '';
|
||||
const pb = bottomPadding ? `pb-[${bottomPadding}]` : '';
|
||||
wrapperClasses.push(pt, pb);
|
||||
}
|
||||
|
||||
// Handle full screen
|
||||
if (fullScreen === 'middle') {
|
||||
wrapperClasses.push('min-h-screen', 'flex', 'items-center');
|
||||
}
|
||||
|
||||
return `<div class="${wrapperClasses.join(' ')}" style="${style}">${content}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_column] shortcodes
|
||||
*/
|
||||
function processVcColumnShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_column([^\]]*)\]([\s\S]*?)\[\/vc_column\]/g, (match, attrs, content) => {
|
||||
const width = extractAttribute(attrs, 'width') || '12';
|
||||
const classes = ['vc-column', 'px-4'];
|
||||
|
||||
// Convert width to Tailwind classes
|
||||
if (width === '12' || width === 'full') {
|
||||
classes.push('w-full');
|
||||
} else if (width === '6') {
|
||||
classes.push('w-full', 'md:w-1/2');
|
||||
} else if (width === '4') {
|
||||
classes.push('w-full', 'md:w-1/3');
|
||||
} else if (width === '3') {
|
||||
classes.push('w-full', 'md:w-1/4');
|
||||
}
|
||||
|
||||
return `<div class="${classes.join(' ')}">${content}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_column_text] shortcodes
|
||||
*/
|
||||
function processVcColumnTextShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_column_text([^\]]*)\]([\s\S]*?)\[\/vc_column_text\]/g, (match, attrs, content) => {
|
||||
const classes = ['vc-column-text', 'prose', 'max-w-none'];
|
||||
|
||||
// Handle text alignment
|
||||
const align = extractAttribute(attrs, 'text_align');
|
||||
if (align === 'center') classes.push('text-center');
|
||||
if (align === 'right') classes.push('text-right');
|
||||
|
||||
return `<div class="${classes.join(' ')}">${content}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_single_image] shortcodes
|
||||
*/
|
||||
function processVcImageShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_single_image([^\]]*)\]/g, (match, attrs) => {
|
||||
const imageId = extractAttribute(attrs, 'src') || extractAttribute(attrs, 'image');
|
||||
const align = extractAttribute(attrs, 'align') || 'none';
|
||||
const width = extractAttribute(attrs, 'width');
|
||||
|
||||
const classes = ['vc-single-image', 'my-4'];
|
||||
|
||||
// Handle alignment
|
||||
if (align === 'center') classes.push('mx-auto');
|
||||
if (align === 'left') classes.push('float-left', 'mr-4', 'mb-4');
|
||||
if (align === 'right') classes.push('float-right', 'ml-4', 'mb-4');
|
||||
|
||||
// Use data attribute for image ID to be processed by ContentRenderer
|
||||
return `<img class="${classes.join(' ')}" data-wp-image-id="${imageId}" data-width="${width || ''}" alt="" />`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_btn] and [vc_button] shortcodes
|
||||
*/
|
||||
function processVcButtonShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_btn([^\]]*)\]/g, (match, attrs) => {
|
||||
const title = extractAttribute(attrs, 'title') || 'Click Here';
|
||||
const href = extractAttribute(attrs, 'href') || extractAttribute(attrs, 'link');
|
||||
const color = extractAttribute(attrs, 'color') || 'primary';
|
||||
const size = extractAttribute(attrs, 'size') || 'md';
|
||||
|
||||
const classes = ['vc-btn', 'inline-flex', 'items-center', 'justify-center', 'px-4', 'py-2', 'rounded-lg', 'font-semibold', 'transition-colors', 'duration-200'];
|
||||
|
||||
// Color mapping
|
||||
if (color === 'primary' || color === 'skype') classes.push('bg-primary', 'text-white', 'hover:bg-primary-dark');
|
||||
else if (color === 'secondary') classes.push('bg-secondary', 'text-white', 'hover:bg-secondary-light');
|
||||
else if (color === 'ghost' || color === 'outline') classes.push('border-2', 'border-primary', 'text-primary', 'hover:bg-primary', 'hover:text-white');
|
||||
else if (color === 'white') classes.push('bg-white', 'text-gray-900', 'hover:bg-gray-100');
|
||||
|
||||
// Size mapping
|
||||
if (size === 'lg' || size === 'large') classes.push('px-6', 'py-3', 'text-lg');
|
||||
if (size === 'sm' || size === 'small') classes.push('px-3', 'py-1', 'text-sm');
|
||||
|
||||
if (href) {
|
||||
return `<a href="${href}" class="${classes.join(' ')}" target="_blank" rel="noopener noreferrer">${title}</a>`;
|
||||
}
|
||||
|
||||
return `<button class="${classes.join(' ')}">${title}</button>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_separator] and [vc_text_separator] shortcodes
|
||||
*/
|
||||
function processVcSeparatorShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_separator([^\]]*)\]/g, (match, attrs) => {
|
||||
const color = extractAttribute(attrs, 'color') || 'default';
|
||||
const width = extractAttribute(attrs, 'width') || '100';
|
||||
const thickness = extractAttribute(attrs, 'thickness') || '1';
|
||||
|
||||
const classes = ['vc-separator', 'my-6'];
|
||||
|
||||
// Color mapping
|
||||
if (color === 'primary') classes.push('border-primary');
|
||||
else if (color === 'secondary') classes.push('border-secondary');
|
||||
else if (color === 'white') classes.push('border-white');
|
||||
else classes.push('border-gray-300');
|
||||
|
||||
// Width and thickness
|
||||
const style = `width: ${width}%; border-top-width: ${thickness}px;`;
|
||||
|
||||
return `<hr class="${classes.join(' ')}" style="${style}" />`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process [vc_video] shortcodes
|
||||
*/
|
||||
function processVcVideoShortcodes(html: string): string {
|
||||
return html.replace(/\[vc_video([^\]]*)\]/g, (match, attrs) => {
|
||||
const link = extractAttribute(attrs, 'link');
|
||||
const mp4 = extractAttribute(attrs, 'mp4');
|
||||
const webm = extractAttribute(attrs, 'webm');
|
||||
|
||||
if (mp4 || webm) {
|
||||
// Video background
|
||||
const poster = extractAttribute(attrs, 'poster');
|
||||
return `<div class="vc-video bg-black relative overflow-hidden rounded-lg my-4">
|
||||
<video class="w-full" ${poster ? `poster="${poster}"` : ''} autoPlay loop muted playsInline>
|
||||
${mp4 ? `<source src="${mp4}" type="video/mp4">` : ''}
|
||||
${webm ? `<source src="${webm}" type="video/webm">` : ''}
|
||||
</video>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
// Embedded video (YouTube, Vimeo, etc.)
|
||||
return `<div class="vc-video embed-responsive aspect-video my-4">
|
||||
<iframe src="${link}" frameborder="0" allowfullscreen class="w-full h-full"></iframe>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process background-related shortcodes and attributes
|
||||
*/
|
||||
function processBackgroundShortcodes(html: string): string {
|
||||
// Handle background image attributes in divs
|
||||
html = html.replace(/bg_image="(\d+)"/g, (match, imageId) => {
|
||||
return `data-bg-image="${imageId}"`;
|
||||
});
|
||||
|
||||
// Handle video background attributes
|
||||
html = html.replace(/video_bg="use_video"/g, 'data-video-bg="true"');
|
||||
html = html.replace(/video_mp4="([^"]+)"/g, (match, url) => `data-video-mp4="${url}"`);
|
||||
html = html.replace(/video_webm="([^"]+)"/g, (match, url) => `data-video-webm="${url}"`);
|
||||
|
||||
// Handle parallax
|
||||
html = html.replace(/parallax_bg="true"/g, 'data-parallax="true"');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attribute value from shortcode attributes
|
||||
* Handles complex patterns with quotes, special characters, and spaces
|
||||
*/
|
||||
function extractAttribute(attrs: string, key: string): string | null {
|
||||
// First try: key="value" or key='value'
|
||||
const quotedPattern = new RegExp(`${key}=["']([^"']*)["']`, 'i');
|
||||
const quotedMatch = attrs.match(quotedPattern);
|
||||
if (quotedMatch) return quotedMatch[1];
|
||||
|
||||
// Second try: key=value (without quotes, until space or end)
|
||||
const unquotedPattern = new RegExp(`${key}=([^\\s\\]]+)`, 'i');
|
||||
const unquotedMatch = attrs.match(unquotedPattern);
|
||||
if (unquotedMatch) return unquotedMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tailwind gradient class from gradient direction
|
||||
*/
|
||||
function getGradientClass(direction: string): string {
|
||||
const gradientMap: Record<string, string> = {
|
||||
'left_to_right': 'bg-gradient-to-r from-primary to-secondary',
|
||||
'right_to_left': 'bg-gradient-to-l from-primary to-secondary',
|
||||
'top_to_bottom': 'bg-gradient-to-b from-primary to-secondary',
|
||||
'bottom_to_top': 'bg-gradient-to-t from-primary to-secondary',
|
||||
'left_t_to_right_b': 'bg-gradient-to-br from-primary to-secondary',
|
||||
'default': 'bg-gradient-to-r from-primary to-secondary',
|
||||
};
|
||||
|
||||
return gradientMap[direction] || gradientMap['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up whitespace and normalize spacing
|
||||
*/
|
||||
function cleanWhitespace(html: string): string {
|
||||
let processed = html;
|
||||
|
||||
// Remove empty paragraphs
|
||||
processed = processed.replace(/<p>\s*<\/p>/g, '');
|
||||
processed = processed.replace(/<p>\s* \s*<\/p>/g, '');
|
||||
|
||||
// Remove multiple spaces
|
||||
processed = processed.replace(/\s+/g, ' ');
|
||||
|
||||
// Remove spaces around block elements
|
||||
processed = processed.replace(/\s*(<\/?(h[1-6]|div|section|article|p|ul|ol|li|table|tr|td|th|blockquote|figure|figcaption|br|hr)\s*>)\s*/g, '$1');
|
||||
|
||||
// Trim
|
||||
processed = processed.trim();
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert WordPress/Salient classes to Tailwind equivalents
|
||||
*/
|
||||
export function convertWordPressClasses(html: string): string {
|
||||
if (!html) return '';
|
||||
|
||||
const classMap: Record<string, string> = {
|
||||
// Layout classes
|
||||
'vc_row': 'flex flex-wrap -mx-4',
|
||||
'vc_row-fluid': 'w-full',
|
||||
'vc_row-full-width': 'w-full -mx-4',
|
||||
'vc_row-o-content-top': 'items-start',
|
||||
'vc_row-o-content-middle': 'items-center',
|
||||
'vc_row-o-content-bottom': 'items-end',
|
||||
|
||||
// Column classes
|
||||
'vc_col-sm-12': 'w-full px-4',
|
||||
'vc_col-md-6': 'w-full md:w-1/2 px-4',
|
||||
'vc_col-md-4': 'w-full md:w-1/3 px-4',
|
||||
'vc_col-md-3': 'w-full md:w-1/4 px-4',
|
||||
'vc_col-lg-6': 'w-full lg:w-1/2 px-4',
|
||||
'vc_col-lg-4': 'w-full lg:w-1/3 px-4',
|
||||
'vc_col-lg-3': 'w-full lg:w-1/4 px-4',
|
||||
'vc_col-xs-12': 'w-full px-4',
|
||||
|
||||
// Wrapper classes
|
||||
'wpb_wrapper': 'space-y-4',
|
||||
'wpb_text_column': 'prose max-w-none',
|
||||
'wpb_content_element': 'mb-8',
|
||||
'wpb_single_image': 'my-4',
|
||||
|
||||
// Typography
|
||||
'wpb_heading': 'text-2xl font-bold mb-2',
|
||||
'wpb_button': 'inline-block px-4 py-2 rounded-lg font-semibold',
|
||||
|
||||
// Alignment
|
||||
'text-left': 'text-left',
|
||||
'text-center': 'text-center',
|
||||
'text-right': 'text-right',
|
||||
'alignleft': 'float-left mr-4 mb-4',
|
||||
'alignright': 'float-right ml-4 mb-4',
|
||||
'aligncenter': 'mx-auto',
|
||||
|
||||
// Colors
|
||||
'accent-color': 'text-primary',
|
||||
'primary-color': 'text-primary',
|
||||
'secondary-color': 'text-secondary',
|
||||
'text-color': 'text-gray-800',
|
||||
'light-text': 'text-gray-300',
|
||||
'dark-text': 'text-gray-900',
|
||||
|
||||
// Backgrounds
|
||||
'bg-light': 'bg-gray-50',
|
||||
'bg-light-gray': 'bg-gray-100',
|
||||
'bg-dark': 'bg-gray-900',
|
||||
'bg-dark-gray': 'bg-gray-800',
|
||||
'bg-primary': 'bg-primary',
|
||||
'bg-secondary': 'bg-secondary',
|
||||
'bg-white': 'bg-white',
|
||||
'bg-transparent': 'bg-transparent',
|
||||
|
||||
// Buttons
|
||||
'btn': 'inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold transition-colors duration-200',
|
||||
'btn-primary': 'bg-primary text-white hover:bg-primary-dark',
|
||||
'btn-secondary': 'bg-secondary text-white hover:bg-secondary-light',
|
||||
'btn-outline': 'border-2 border-primary text-primary hover:bg-primary hover:text-white',
|
||||
'btn-large': 'px-6 py-3 text-lg',
|
||||
'btn-small': 'px-3 py-1 text-sm',
|
||||
|
||||
// Spacing
|
||||
'mt-0': 'mt-0',
|
||||
'mb-0': 'mb-0',
|
||||
'mt-2': 'mt-2',
|
||||
'mb-2': 'mb-2',
|
||||
'mt-4': 'mt-4',
|
||||
'mb-4': 'mb-4',
|
||||
'mt-6': 'mt-6',
|
||||
'mb-6': 'mb-6',
|
||||
'mt-8': 'mt-8',
|
||||
'mb-8': 'mb-8',
|
||||
'mt-12': 'mt-12',
|
||||
'mb-12': 'mb-12',
|
||||
|
||||
// Containers
|
||||
'container': 'container mx-auto px-4',
|
||||
'container-fluid': 'w-full px-4',
|
||||
|
||||
// Visibility
|
||||
'hidden': 'hidden',
|
||||
'visible': 'visible',
|
||||
'block': 'block',
|
||||
'inline': 'inline',
|
||||
'inline-block': 'inline-block',
|
||||
|
||||
// Borders
|
||||
'border': 'border',
|
||||
'border-0': 'border-0',
|
||||
'border-t': 'border-t',
|
||||
'border-b': 'border-b',
|
||||
'border-l': 'border-l',
|
||||
'border-r': 'border-r',
|
||||
|
||||
// Shadows
|
||||
'shadow': 'shadow',
|
||||
'shadow-sm': 'shadow-sm',
|
||||
'shadow-md': 'shadow-md',
|
||||
'shadow-lg': 'shadow-lg',
|
||||
'shadow-xl': 'shadow-xl',
|
||||
|
||||
// Rounded
|
||||
'rounded': 'rounded',
|
||||
'rounded-none': 'rounded-none',
|
||||
'rounded-sm': 'rounded-sm',
|
||||
'rounded-lg': 'rounded-lg',
|
||||
'rounded-full': 'rounded-full',
|
||||
|
||||
// Overflow
|
||||
'overflow-hidden': 'overflow-hidden',
|
||||
'overflow-auto': 'overflow-auto',
|
||||
'overflow-scroll': 'overflow-scroll',
|
||||
|
||||
// Position
|
||||
'relative': 'relative',
|
||||
'absolute': 'absolute',
|
||||
'fixed': 'fixed',
|
||||
'sticky': 'sticky',
|
||||
|
||||
// Z-index
|
||||
'z-0': 'z-0',
|
||||
'z-10': 'z-10',
|
||||
'z-20': 'z-20',
|
||||
'z-30': 'z-30',
|
||||
'z-40': 'z-40',
|
||||
'z-50': 'z-50',
|
||||
|
||||
// Width
|
||||
'w-full': 'w-full',
|
||||
'w-1/2': 'w-1/2',
|
||||
'w-1/3': 'w-1/3',
|
||||
'w-2/3': 'w-2/3',
|
||||
'w-1/4': 'w-1/4',
|
||||
'w-3/4': 'w-3/4',
|
||||
|
||||
// Height
|
||||
'h-full': 'h-full',
|
||||
'h-screen': 'h-screen',
|
||||
'h-32': 'h-32',
|
||||
'h-48': 'h-48',
|
||||
'h-64': 'h-64',
|
||||
|
||||
// Flexbox
|
||||
'flex': 'flex',
|
||||
'flex-col': 'flex-col',
|
||||
'flex-row': 'flex-row',
|
||||
'flex-wrap': 'flex-wrap',
|
||||
'flex-nowrap': 'flex-nowrap',
|
||||
'justify-start': 'justify-start',
|
||||
'justify-center': 'justify-center',
|
||||
'justify-end': 'justify-end',
|
||||
'justify-between': 'justify-between',
|
||||
'justify-around': 'justify-around',
|
||||
'items-start': 'items-start',
|
||||
'items-center': 'items-center',
|
||||
'items-end': 'items-end',
|
||||
'items-stretch': 'items-stretch',
|
||||
|
||||
// Grid (if used)
|
||||
'grid': 'grid',
|
||||
'grid-cols-1': 'grid-cols-1',
|
||||
'grid-cols-2': 'grid-cols-2',
|
||||
'grid-cols-3': 'grid-cols-3',
|
||||
'grid-cols-4': 'grid-cols-4',
|
||||
'gap-2': 'gap-2',
|
||||
'gap-4': 'gap-4',
|
||||
'gap-6': 'gap-6',
|
||||
'gap-8': 'gap-8',
|
||||
|
||||
// Padding
|
||||
'p-0': 'p-0',
|
||||
'p-2': 'p-2',
|
||||
'p-4': 'p-4',
|
||||
'p-6': 'p-6',
|
||||
'p-8': 'p-8',
|
||||
'p-12': 'p-12',
|
||||
'px-4': 'px-4',
|
||||
'py-4': 'py-4',
|
||||
'pt-4': 'pt-4',
|
||||
'pb-4': 'pb-4',
|
||||
|
||||
// Margin
|
||||
'm-0': 'm-0',
|
||||
'm-2': 'm-2',
|
||||
'm-4': 'm-4',
|
||||
'm-6': 'm-6',
|
||||
'm-8': 'm-8',
|
||||
'mx-auto': 'mx-auto',
|
||||
|
||||
// Text transform
|
||||
'uppercase': 'uppercase',
|
||||
'lowercase': 'lowercase',
|
||||
'capitalize': 'capitalize',
|
||||
'normal-case': 'normal-case',
|
||||
|
||||
// Font weight
|
||||
'font-light': 'font-light',
|
||||
'font-normal': 'font-normal',
|
||||
'font-medium': 'font-medium',
|
||||
'font-semibold': 'font-semibold',
|
||||
'font-bold': 'font-bold',
|
||||
|
||||
// Text size
|
||||
'text-xs': 'text-xs',
|
||||
'text-sm': 'text-sm',
|
||||
'text-base': 'text-base',
|
||||
'text-lg': 'text-lg',
|
||||
'text-xl': 'text-xl',
|
||||
'text-2xl': 'text-2xl',
|
||||
'text-3xl': 'text-3xl',
|
||||
'text-4xl': 'text-4xl',
|
||||
|
||||
// Text color
|
||||
'text-white': 'text-white',
|
||||
'text-black': 'text-black',
|
||||
'text-gray-100': 'text-gray-100',
|
||||
'text-gray-200': 'text-gray-200',
|
||||
'text-gray-300': 'text-gray-300',
|
||||
'text-gray-400': 'text-gray-400',
|
||||
'text-gray-500': 'text-gray-500',
|
||||
'text-gray-600': 'text-gray-600',
|
||||
'text-gray-700': 'text-gray-700',
|
||||
'text-gray-800': 'text-gray-800',
|
||||
'text-gray-900': 'text-gray-900',
|
||||
|
||||
// Background color (continued)
|
||||
'bg-gray-100': 'bg-gray-100',
|
||||
'bg-gray-200': 'bg-gray-200',
|
||||
'bg-gray-300': 'bg-gray-300',
|
||||
'bg-gray-400': 'bg-gray-400',
|
||||
'bg-gray-500': 'bg-gray-500',
|
||||
'bg-gray-600': 'bg-gray-600',
|
||||
'bg-gray-700': 'bg-gray-700',
|
||||
'bg-gray-800': 'bg-gray-800',
|
||||
'bg-gray-900': 'bg-gray-900',
|
||||
|
||||
// Opacity
|
||||
'opacity-0': 'opacity-0',
|
||||
'opacity-25': 'opacity-25',
|
||||
'opacity-50': 'opacity-50',
|
||||
'opacity-75': 'opacity-75',
|
||||
'opacity-100': 'opacity-100',
|
||||
|
||||
// Display (continued)
|
||||
'inline-flex': 'inline-flex',
|
||||
'contents': 'contents',
|
||||
|
||||
// Object fit
|
||||
'object-cover': 'object-cover',
|
||||
'object-contain': 'object-contain',
|
||||
'object-fill': 'object-fill',
|
||||
'object-none': 'object-none',
|
||||
'object-scale-down': 'object-scale-down',
|
||||
|
||||
// Aspect ratio
|
||||
'aspect-square': 'aspect-square',
|
||||
'aspect-video': 'aspect-video',
|
||||
'aspect-[4/3]': 'aspect-[4/3]',
|
||||
'aspect-[16/9]': 'aspect-[16/9]',
|
||||
|
||||
// Transforms
|
||||
'transform': 'transform',
|
||||
'scale-95': 'scale-95',
|
||||
'scale-100': 'scale-100',
|
||||
'scale-105': 'scale-105',
|
||||
'rotate-0': 'rotate-0',
|
||||
'rotate-45': 'rotate-45',
|
||||
'rotate-90': 'rotate-90',
|
||||
|
||||
// Transitions
|
||||
'transition': 'transition',
|
||||
'transition-all': 'transition-all',
|
||||
'transition-colors': 'transition-colors',
|
||||
'transition-opacity': 'transition-opacity',
|
||||
'transition-transform': 'transition-transform',
|
||||
'duration-150': 'duration-150',
|
||||
'duration-200': 'duration-200',
|
||||
'duration-300': 'duration-300',
|
||||
'duration-500': 'duration-500',
|
||||
|
||||
// Hover states (these will be handled differently)
|
||||
'hover:bg-primary': 'hover:bg-primary',
|
||||
'hover:text-white': 'hover:text-white',
|
||||
'hover:scale-105': 'hover:scale-105',
|
||||
|
||||
// Focus states
|
||||
'focus:outline-none': 'focus:outline-none',
|
||||
'focus:ring-2': 'focus:ring-2',
|
||||
'focus:ring-primary': 'focus:ring-primary',
|
||||
|
||||
// Responsive prefixes
|
||||
'sm:block': 'sm:block',
|
||||
'sm:hidden': 'sm:hidden',
|
||||
'md:block': 'md:block',
|
||||
'md:hidden': 'md:hidden',
|
||||
'lg:block': 'lg:block',
|
||||
'lg:hidden': 'lg:hidden',
|
||||
'xl:block': 'xl:block',
|
||||
'xl:hidden': 'xl:hidden',
|
||||
|
||||
// Custom utility classes for the project
|
||||
'section-padding': 'py-12 md:py-16',
|
||||
'container-narrow': 'max-w-4xl mx-auto',
|
||||
'container-wide': 'max-w-6xl mx-auto',
|
||||
'text-gradient': 'bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent',
|
||||
|
||||
// Animation classes
|
||||
'animate-fade-in': 'animate-fade-in',
|
||||
'animate-fade-in-up': 'animate-fade-in-up',
|
||||
'animate-slide-in': 'animate-slide-in',
|
||||
'animate-bounce': 'animate-bounce',
|
||||
'animate-pulse': 'animate-pulse',
|
||||
'animate-spin': 'animate-spin',
|
||||
|
||||
// Custom classes for WordPress compatibility
|
||||
'wp-caption': 'figure',
|
||||
'wp-caption-text': 'figcaption text-sm text-gray-600 mt-2',
|
||||
'alignnone': 'block',
|
||||
'size-full': 'w-full',
|
||||
'size-large': 'w-full max-w-3xl',
|
||||
'size-medium': 'w-full max-w-xl',
|
||||
'size-thumbnail': 'w-32 h-32',
|
||||
};
|
||||
|
||||
let processed = html;
|
||||
|
||||
// Replace classes in HTML attributes
|
||||
Object.entries(classMap).forEach(([wpClass, twClass]) => {
|
||||
// Handle class="..." with the class at the beginning
|
||||
const classRegex1 = new RegExp(`class=["']${wpClass}\\s+([^"']*)["']`, 'g');
|
||||
processed = processed.replace(classRegex1, (match, rest) => {
|
||||
const newClasses = `${twClass} ${rest}`.trim().replace(/\s+/g, ' ');
|
||||
return `class="${newClasses}"`;
|
||||
});
|
||||
|
||||
// Handle class="..." with the class in the middle
|
||||
const classRegex2 = new RegExp(`class=["']([^"']*)\\s+${wpClass}\\s+([^"']*)["']`, 'g');
|
||||
processed = processed.replace(classRegex2, (match, before, after) => {
|
||||
const newClasses = `${before} ${twClass} ${after}`.trim().replace(/\s+/g, ' ');
|
||||
return `class="${newClasses}"`;
|
||||
});
|
||||
|
||||
// Handle class="..." with the class at the end
|
||||
const classRegex3 = new RegExp(`class=["']([^"']*)\\s+${wpClass}["']`, 'g');
|
||||
processed = processed.replace(classRegex3, (match, before) => {
|
||||
const newClasses = `${before} ${twClass}`.trim().replace(/\s+/g, ' ');
|
||||
return `class="${newClasses}"`;
|
||||
});
|
||||
|
||||
// Handle class="..." with only the class
|
||||
const classRegex4 = new RegExp(`class=["']${wpClass}["']`, 'g');
|
||||
processed = processed.replace(classRegex4, `class="${twClass}"`);
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from HTML (strip all tags)
|
||||
*/
|
||||
export function stripHTML(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from HTML and process it
|
||||
*/
|
||||
export function extractTextFromHTML(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
return processHTML(html);
|
||||
return processHTML(html).replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,4 +840,49 @@ export function getDictionary(locale: string): Record<string, string> {
|
||||
// For now, return empty dictionary
|
||||
// In a real implementation, this would load translation files
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process HTML for preview (shorter, sanitized)
|
||||
*/
|
||||
export function processHTMLForPreview(html: string | null | undefined, maxLength: number = 200): string {
|
||||
if (!html) return '';
|
||||
|
||||
const processed = processHTML(html);
|
||||
const stripped = stripHTML(processed);
|
||||
|
||||
if (stripped.length <= maxLength) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
return stripped.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTML contains dangerous content
|
||||
*/
|
||||
export function hasDangerousContent(html: string | null | undefined): boolean {
|
||||
if (!html) return false;
|
||||
|
||||
const dangerousPatterns = [
|
||||
/<script\b/i,
|
||||
/javascript:/i,
|
||||
/on\w+\s*=/i,
|
||||
/<style\b/i,
|
||||
/expression\s*\(/i,
|
||||
/vbscript:/i,
|
||||
/data:text\/html/i,
|
||||
];
|
||||
|
||||
return dangerousPatterns.some(pattern => pattern.test(html));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize HTML for comparison
|
||||
*/
|
||||
export function normalizeHTML(html: string): string {
|
||||
return processHTML(html)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/> </g, '><')
|
||||
.trim();
|
||||
}
|
||||
42
lib/i18n.ts
42
lib/i18n.ts
@@ -28,9 +28,14 @@ const translations = {
|
||||
},
|
||||
blog: {
|
||||
title: 'Blog',
|
||||
description: 'Latest news and insights about cables and energy',
|
||||
readMore: 'Read more',
|
||||
noPosts: 'No posts available.',
|
||||
backToBlog: '← Back to Blog',
|
||||
categories: 'Categories',
|
||||
featured: 'Featured Posts',
|
||||
allPosts: 'All Posts',
|
||||
noPostsDescription: 'Check back soon for new content.',
|
||||
},
|
||||
products: {
|
||||
title: 'Products',
|
||||
@@ -59,6 +64,7 @@ const translations = {
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us',
|
||||
subtitle: 'Get in touch with our team',
|
||||
name: 'Your Name',
|
||||
email: 'Your Email',
|
||||
message: 'Your Message',
|
||||
@@ -67,7 +73,16 @@ const translations = {
|
||||
error: 'Failed to send message. Please try again.',
|
||||
processing: 'Sending...',
|
||||
phone: 'Phone (optional)',
|
||||
subject: 'Subject',
|
||||
company: 'Company (optional)',
|
||||
requiredFields: 'Required fields are marked with *',
|
||||
sending: 'Sending...',
|
||||
errors: {
|
||||
nameRequired: 'Please enter your name',
|
||||
emailRequired: 'Please enter your email address',
|
||||
emailInvalid: 'Please enter a valid email address',
|
||||
messageRequired: 'Please enter your message',
|
||||
},
|
||||
},
|
||||
consent: {
|
||||
title: 'Cookie & Analytics Consent',
|
||||
@@ -77,6 +92,12 @@ const translations = {
|
||||
analytics: 'Analytics',
|
||||
analyticsDesc: 'Help us understand how visitors use our site',
|
||||
},
|
||||
cookieConsent: {
|
||||
message: 'We use cookies to enhance your browsing experience and analyze our traffic.',
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
decline: 'Decline',
|
||||
accept: 'Accept',
|
||||
},
|
||||
footer: {
|
||||
rights: 'All rights reserved.',
|
||||
madeWith: 'Made with Next.js',
|
||||
@@ -125,9 +146,14 @@ const translations = {
|
||||
},
|
||||
blog: {
|
||||
title: 'Blog',
|
||||
description: 'Aktuelle Neuigkeiten und Einblicke über Kabel und Energie',
|
||||
readMore: 'Weiterlesen',
|
||||
noPosts: 'Keine Beiträge verfügbar.',
|
||||
backToBlog: '← Zurück zum Blog',
|
||||
categories: 'Kategorien',
|
||||
featured: 'Beiträge',
|
||||
allPosts: 'Alle Beiträge',
|
||||
noPostsDescription: 'Schauen Sie bald wieder vorbei für neue Inhalte.',
|
||||
},
|
||||
products: {
|
||||
title: 'Produkte',
|
||||
@@ -156,6 +182,7 @@ const translations = {
|
||||
},
|
||||
contact: {
|
||||
title: 'Kontakt',
|
||||
subtitle: 'Nehmen Sie Kontakt mit unserem Team auf',
|
||||
name: 'Ihr Name',
|
||||
email: 'Ihre E-Mail',
|
||||
message: 'Ihre Nachricht',
|
||||
@@ -164,7 +191,16 @@ const translations = {
|
||||
error: 'Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es erneut.',
|
||||
processing: 'Wird gesendet...',
|
||||
phone: 'Telefon (optional)',
|
||||
subject: 'Betreff',
|
||||
company: 'Firma (optional)',
|
||||
requiredFields: 'Pflichtfelder sind mit * markiert',
|
||||
sending: 'Wird gesendet...',
|
||||
errors: {
|
||||
nameRequired: 'Bitte geben Sie Ihren Namen ein',
|
||||
emailRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
|
||||
emailInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein',
|
||||
messageRequired: 'Bitte geben Sie Ihre Nachricht ein',
|
||||
},
|
||||
},
|
||||
consent: {
|
||||
title: 'Cookie- & Analyse-Einwilligung',
|
||||
@@ -174,6 +210,12 @@ const translations = {
|
||||
analytics: 'Analyse',
|
||||
analyticsDesc: 'Helfen Sie uns zu verstehen, wie Besucher unsere Seite nutzen',
|
||||
},
|
||||
cookieConsent: {
|
||||
message: 'Wir verwenden Cookies, um Ihr Surferlebnis zu verbessern und unseren Traffic zu analysieren.',
|
||||
privacyPolicy: 'Datenschutzrichtlinie',
|
||||
decline: 'Ablehnen',
|
||||
accept: 'Akzeptieren',
|
||||
},
|
||||
footer: {
|
||||
rights: 'Alle Rechte vorbehalten.',
|
||||
madeWith: 'Erstellt mit Next.js',
|
||||
|
||||
326
lib/responsive-test.ts
Normal file
326
lib/responsive-test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Responsive Testing Utilities for KLZ Cables
|
||||
* Tools for testing and validating responsive design
|
||||
*/
|
||||
|
||||
import { BREAKPOINTS, getViewport, Viewport } from './responsive';
|
||||
|
||||
// Test viewport configurations
|
||||
export const TEST_VIEWPORTS = {
|
||||
mobile: {
|
||||
width: 375,
|
||||
height: 667,
|
||||
name: 'Mobile (iPhone SE)',
|
||||
breakpoint: 'xs',
|
||||
},
|
||||
mobileLarge: {
|
||||
width: 414,
|
||||
height: 896,
|
||||
name: 'Mobile Large (iPhone 11)',
|
||||
breakpoint: 'sm',
|
||||
},
|
||||
tablet: {
|
||||
width: 768,
|
||||
height: 1024,
|
||||
name: 'Tablet (iPad)',
|
||||
breakpoint: 'md',
|
||||
},
|
||||
tabletLandscape: {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
name: 'Tablet Landscape',
|
||||
breakpoint: 'lg',
|
||||
},
|
||||
desktop: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
name: 'Desktop (Laptop)',
|
||||
breakpoint: 'xl',
|
||||
},
|
||||
desktopLarge: {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
name: 'Desktop Large',
|
||||
breakpoint: '2xl',
|
||||
},
|
||||
desktopWide: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
name: 'Desktop Wide (Full HD)',
|
||||
breakpoint: '3xl',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Responsive Design Checklist
|
||||
* Comprehensive checklist for validating responsive design
|
||||
*/
|
||||
export const RESPONSIVE_CHECKLIST = {
|
||||
layout: [
|
||||
'Content stacks properly on mobile (1 column)',
|
||||
'Grid layouts adapt to screen size (2-4 columns)',
|
||||
'No horizontal scrolling at any breakpoint',
|
||||
'Content remains within safe areas',
|
||||
'Padding and margins scale appropriately',
|
||||
],
|
||||
typography: [
|
||||
'Text remains readable at all sizes',
|
||||
'Line height is optimized for mobile',
|
||||
'Headings scale appropriately',
|
||||
'No text overflow or clipping',
|
||||
'Font size meets WCAG guidelines (16px minimum)',
|
||||
],
|
||||
navigation: [
|
||||
'Mobile menu is accessible (44px touch targets)',
|
||||
'Desktop navigation hides on mobile',
|
||||
'Menu items are properly spaced',
|
||||
'Active states are visible',
|
||||
'Back/forward navigation works',
|
||||
],
|
||||
images: [
|
||||
'Images load with appropriate sizes',
|
||||
'Aspect ratios are maintained',
|
||||
'No layout shift during loading',
|
||||
'Lazy loading works correctly',
|
||||
'Placeholder blur is applied',
|
||||
],
|
||||
forms: [
|
||||
'Input fields are 44px minimum touch target',
|
||||
'Labels remain visible',
|
||||
'Error messages are readable',
|
||||
'Form submits on mobile',
|
||||
'Keyboard navigation works',
|
||||
],
|
||||
performance: [
|
||||
'Images are properly sized for viewport',
|
||||
'No unnecessary large assets on mobile',
|
||||
'Critical CSS is loaded',
|
||||
'Touch interactions are smooth',
|
||||
'No layout thrashing',
|
||||
],
|
||||
accessibility: [
|
||||
'Touch targets are 44px minimum',
|
||||
'Focus indicators are visible',
|
||||
'Screen readers work correctly',
|
||||
'Color contrast meets WCAG AA',
|
||||
'Zoom is not restricted',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate responsive design report
|
||||
*/
|
||||
export function generateResponsiveReport(): string {
|
||||
const viewport = getViewport();
|
||||
|
||||
const report = `
|
||||
Responsive Design Report - KLZ Cables
|
||||
=====================================
|
||||
|
||||
Current Viewport:
|
||||
- Width: ${viewport.width}px
|
||||
- Height: ${viewport.height}px
|
||||
- Breakpoint: ${viewport.breakpoint}
|
||||
- Device Type: ${viewport.isMobile ? 'Mobile' : viewport.isTablet ? 'Tablet' : 'Desktop'}
|
||||
|
||||
Breakpoint Configuration:
|
||||
- xs: ${BREAKPOINTS.xs}px
|
||||
- sm: ${BREAKPOINTS.sm}px
|
||||
- md: ${BREAKPOINTS.md}px
|
||||
- lg: ${BREAKPOINTS.lg}px
|
||||
- xl: ${BREAKPOINTS.xl}px
|
||||
- 2xl: ${BREAKPOINTS['2xl']}px
|
||||
- 3xl: ${BREAKPOINTS['3xl']}px
|
||||
|
||||
Touch Target Verification:
|
||||
- Minimum: 44px × 44px
|
||||
- Recommended: 48px × 48px
|
||||
- Large: 56px × 56px
|
||||
|
||||
Image Optimization:
|
||||
- Mobile Quality: 75%
|
||||
- Tablet Quality: 85%
|
||||
- Desktop Quality: 90%
|
||||
|
||||
Typography Scale:
|
||||
- Fluid typography using CSS clamp()
|
||||
- Mobile: 16px base
|
||||
- Desktop: 18px base
|
||||
- Line height: 1.4-1.6
|
||||
|
||||
Generated: ${new Date().toISOString()}
|
||||
`.trim();
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate responsive design rules
|
||||
*/
|
||||
export function validateResponsiveDesign(): {
|
||||
passed: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
} {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check viewport
|
||||
if (typeof window === 'undefined') {
|
||||
warnings.push('Server-side rendering detected - some checks skipped');
|
||||
}
|
||||
|
||||
// Check minimum touch target size
|
||||
const buttons = document.querySelectorAll('button, a');
|
||||
buttons.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 44 || rect.height < 44) {
|
||||
warnings.push(`Element ${el.tagName} has touch target < 44px`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for horizontal scroll
|
||||
if (document.body.scrollWidth > window.innerWidth) {
|
||||
errors.push('Horizontal scrolling detected');
|
||||
}
|
||||
|
||||
// Check text size
|
||||
const textElements = document.querySelectorAll('p, span, div');
|
||||
textElements.forEach((el) => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
const fontSize = parseFloat(computed.fontSize);
|
||||
if (fontSize < 16 && el.textContent && el.textContent.length > 50) {
|
||||
warnings.push(`Text element ${el.tagName} has font-size < 16px`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
passed: errors.length === 0,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive design utilities for testing
|
||||
*/
|
||||
export const ResponsiveTestUtils = {
|
||||
// Set viewport for testing
|
||||
setViewport: (width: number, height: number) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.innerWidth = width;
|
||||
window.innerHeight = height;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
},
|
||||
|
||||
// Simulate mobile viewport
|
||||
simulateMobile: () => {
|
||||
ResponsiveTestUtils.setViewport(375, 667);
|
||||
},
|
||||
|
||||
// Simulate tablet viewport
|
||||
simulateTablet: () => {
|
||||
ResponsiveTestUtils.setViewport(768, 1024);
|
||||
},
|
||||
|
||||
// Simulate desktop viewport
|
||||
simulateDesktop: () => {
|
||||
ResponsiveTestUtils.setViewport(1280, 800);
|
||||
},
|
||||
|
||||
// Check if element is in viewport
|
||||
isElementInViewport: (element: HTMLElement, offset = 0): boolean => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= -offset &&
|
||||
rect.left >= -offset &&
|
||||
rect.bottom <= (window.innerHeight + offset) &&
|
||||
rect.right <= (window.innerWidth + offset)
|
||||
);
|
||||
},
|
||||
|
||||
// Measure touch target size
|
||||
measureTouchTarget: (element: HTMLElement): { width: number; height: number; valid: boolean } => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
valid: rect.width >= 44 && rect.height >= 44,
|
||||
};
|
||||
},
|
||||
|
||||
// Check text readability
|
||||
checkTextReadability: (element: HTMLElement): { fontSize: number; lineHeight: number; valid: boolean } => {
|
||||
const computed = window.getComputedStyle(element);
|
||||
const fontSize = parseFloat(computed.fontSize);
|
||||
const lineHeight = parseFloat(computed.lineHeight);
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
lineHeight,
|
||||
valid: fontSize >= 16 && lineHeight >= 1.4,
|
||||
};
|
||||
},
|
||||
|
||||
// Generate responsive test report
|
||||
generateTestReport: () => {
|
||||
const viewport = getViewport();
|
||||
const validation = validateResponsiveDesign();
|
||||
|
||||
return {
|
||||
viewport,
|
||||
validation,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Responsive design patterns for common scenarios
|
||||
*/
|
||||
export const RESPONSIVE_PATTERNS = {
|
||||
// Mobile-first card grid
|
||||
cardGrid: {
|
||||
mobile: { columns: 1, gap: '1rem' },
|
||||
tablet: { columns: 2, gap: '1.5rem' },
|
||||
desktop: { columns: 3, gap: '2rem' },
|
||||
},
|
||||
|
||||
// Hero section
|
||||
hero: {
|
||||
mobile: { layout: 'stacked', padding: '2rem 1rem' },
|
||||
tablet: { layout: 'split', padding: '3rem 2rem' },
|
||||
desktop: { layout: 'split', padding: '4rem 3rem' },
|
||||
},
|
||||
|
||||
// Form layout
|
||||
form: {
|
||||
mobile: { columns: 1, fieldWidth: '100%' },
|
||||
tablet: { columns: 2, fieldWidth: '48%' },
|
||||
desktop: { columns: 2, fieldWidth: '48%' },
|
||||
},
|
||||
|
||||
// Navigation
|
||||
navigation: {
|
||||
mobile: { type: 'hamburger', itemsPerScreen: 6 },
|
||||
tablet: { type: 'hybrid', itemsPerScreen: 8 },
|
||||
desktop: { type: 'full', itemsPerScreen: 12 },
|
||||
},
|
||||
|
||||
// Image gallery
|
||||
gallery: {
|
||||
mobile: { columns: 1, aspectRatio: '4:3' },
|
||||
tablet: { columns: 2, aspectRatio: '1:1' },
|
||||
desktop: { columns: 3, aspectRatio: '16:9' },
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
TEST_VIEWPORTS,
|
||||
RESPONSIVE_CHECKLIST,
|
||||
generateResponsiveReport,
|
||||
validateResponsiveDesign,
|
||||
ResponsiveTestUtils,
|
||||
RESPONSIVE_PATTERNS,
|
||||
};
|
||||
496
lib/responsive.ts
Normal file
496
lib/responsive.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Responsive Design Utilities for KLZ Cables
|
||||
* Mobile-first approach with comprehensive breakpoint detection and responsive helpers
|
||||
*/
|
||||
|
||||
// Breakpoint definitions matching Tailwind config
|
||||
export const BREAKPOINTS = {
|
||||
xs: 475,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1400,
|
||||
'3xl': 1600,
|
||||
} as const;
|
||||
|
||||
export type BreakpointKey = keyof typeof BREAKPOINTS;
|
||||
|
||||
// Viewport interface
|
||||
export interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
isLargeDesktop: boolean;
|
||||
breakpoint: BreakpointKey;
|
||||
}
|
||||
|
||||
// Responsive prop interface
|
||||
export interface ResponsiveProp<T> {
|
||||
mobile?: T;
|
||||
tablet?: T;
|
||||
desktop?: T;
|
||||
default?: T;
|
||||
}
|
||||
|
||||
// Visibility options interface
|
||||
export interface VisibilityOptions {
|
||||
mobile?: boolean;
|
||||
tablet?: boolean;
|
||||
desktop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current viewport information (client-side only)
|
||||
*/
|
||||
export function getViewport(): Viewport {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: false,
|
||||
isLargeDesktop: false,
|
||||
breakpoint: 'xs',
|
||||
};
|
||||
}
|
||||
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// Determine breakpoint
|
||||
let breakpoint: BreakpointKey = 'xs';
|
||||
if (width >= BREAKPOINTS['3xl']) breakpoint = '3xl';
|
||||
else if (width >= BREAKPOINTS['2xl']) breakpoint = '2xl';
|
||||
else if (width >= BREAKPOINTS.xl) breakpoint = 'xl';
|
||||
else if (width >= BREAKPOINTS.lg) breakpoint = 'lg';
|
||||
else if (width >= BREAKPOINTS.md) breakpoint = 'md';
|
||||
else if (width >= BREAKPOINTS.sm) breakpoint = 'sm';
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
isMobile: width < BREAKPOINTS.md,
|
||||
isTablet: width >= BREAKPOINTS.md && width < BREAKPOINTS.lg,
|
||||
isDesktop: width >= BREAKPOINTS.lg,
|
||||
isLargeDesktop: width >= BREAKPOINTS.xl,
|
||||
breakpoint,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if viewport matches specific breakpoint conditions
|
||||
*/
|
||||
export function checkBreakpoint(
|
||||
condition: 'mobile' | 'tablet' | 'desktop' | 'largeDesktop' | BreakpointKey,
|
||||
viewport: Viewport
|
||||
): boolean {
|
||||
const conditions = {
|
||||
mobile: viewport.isMobile,
|
||||
tablet: viewport.isTablet,
|
||||
desktop: viewport.isDesktop,
|
||||
largeDesktop: viewport.isLargeDesktop,
|
||||
};
|
||||
|
||||
if (condition in conditions) {
|
||||
return conditions[condition as keyof typeof conditions];
|
||||
}
|
||||
|
||||
// Check specific breakpoint
|
||||
const targetBreakpoint = BREAKPOINTS[condition as BreakpointKey];
|
||||
return viewport.width >= targetBreakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive prop resolver - returns appropriate value based on viewport
|
||||
*/
|
||||
export function resolveResponsiveProp<T>(
|
||||
value: T | ResponsiveProp<T>,
|
||||
viewport: Viewport
|
||||
): T {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const prop = value as ResponsiveProp<T>;
|
||||
|
||||
if (viewport.isMobile && prop.mobile !== undefined) {
|
||||
return prop.mobile;
|
||||
}
|
||||
if (viewport.isTablet && prop.tablet !== undefined) {
|
||||
return prop.tablet;
|
||||
}
|
||||
if (viewport.isDesktop && prop.desktop !== undefined) {
|
||||
return prop.desktop;
|
||||
}
|
||||
|
||||
return (prop.default ?? Object.values(prop)[0]) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive image sizes attribute
|
||||
*/
|
||||
export function generateImageSizes(): string {
|
||||
return '(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal image dimensions for different breakpoints
|
||||
*/
|
||||
export function getImageDimensionsForBreakpoint(
|
||||
breakpoint: BreakpointKey,
|
||||
aspectRatio: number = 16 / 9
|
||||
) {
|
||||
const baseWidths = {
|
||||
xs: 400,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1400,
|
||||
'3xl': 1600,
|
||||
};
|
||||
|
||||
const width = baseWidths[breakpoint];
|
||||
const height = Math.round(width / aspectRatio);
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive srcset for images
|
||||
*/
|
||||
export function generateSrcset(
|
||||
baseUrl: string,
|
||||
formats: string[] = ['webp', 'jpg']
|
||||
): string {
|
||||
const sizes = [480, 640, 768, 1024, 1280, 1600];
|
||||
|
||||
return formats
|
||||
.map(format =>
|
||||
sizes
|
||||
.map(size => `${baseUrl}-${size}w.${format} ${size}w`)
|
||||
.join(', ')
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is in viewport (for lazy loading)
|
||||
*/
|
||||
export function isInViewport(element: HTMLElement, offset = 0): boolean {
|
||||
if (!element || typeof window === 'undefined') return false;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
rect.top >= -offset &&
|
||||
rect.left >= -offset &&
|
||||
rect.bottom <= (window.innerHeight + offset) &&
|
||||
rect.right <= (window.innerWidth + offset)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive CSS clamp values for typography
|
||||
*/
|
||||
export function clamp(
|
||||
min: number,
|
||||
preferred: number,
|
||||
max: number,
|
||||
unit: 'rem' | 'px' = 'rem'
|
||||
): string {
|
||||
const minVal = unit === 'rem' ? `${min}rem` : `${min}px`;
|
||||
const maxVal = unit === 'rem' ? `${max}rem` : `${max}px`;
|
||||
const preferredVal = `${preferred}vw`;
|
||||
|
||||
return `clamp(${minVal}, ${preferredVal}, ${maxVal})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get touch target size based on device type
|
||||
*/
|
||||
export function getTouchTargetSize(isMobile: boolean, isLargeDesktop: boolean): string {
|
||||
if (isLargeDesktop) return '72px'; // lg
|
||||
if (isMobile) return '44px'; // sm (minimum)
|
||||
return '56px'; // md
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive spacing utility
|
||||
*/
|
||||
export function getResponsiveSpacing(
|
||||
base: number,
|
||||
viewport: Viewport,
|
||||
multiplier: { mobile?: number; tablet?: number; desktop?: number } = {}
|
||||
): string {
|
||||
const { isMobile, isTablet, isDesktop } = viewport;
|
||||
|
||||
let factor = 1;
|
||||
if (isMobile) factor = multiplier.mobile ?? 1;
|
||||
else if (isTablet) factor = multiplier.tablet ?? 1.25;
|
||||
else if (isDesktop) factor = multiplier.desktop ?? 1.5;
|
||||
|
||||
return `${base * factor}rem`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive grid template
|
||||
*/
|
||||
export function getResponsiveGrid(
|
||||
viewport: Viewport,
|
||||
options: {
|
||||
mobile?: number;
|
||||
tablet?: number;
|
||||
desktop?: number;
|
||||
gap?: string;
|
||||
} = {}
|
||||
): { columns: number; gap: string } {
|
||||
const { isMobile, isTablet, isDesktop } = viewport;
|
||||
|
||||
const columns = isMobile
|
||||
? (options.mobile ?? 1)
|
||||
: isTablet
|
||||
? (options.tablet ?? 2)
|
||||
: (options.desktop ?? 3);
|
||||
|
||||
const gap = options.gap ?? (isMobile ? '1rem' : isTablet ? '1.5rem' : '2rem');
|
||||
|
||||
return { columns, gap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if touch device
|
||||
*/
|
||||
export function isTouchDevice(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
return (
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
(navigator as any).msMaxTouchPoints > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive meta tag content
|
||||
*/
|
||||
export function generateViewportMeta(): string {
|
||||
return 'width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5, minimum-scale=1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive text truncation with ellipsis
|
||||
*/
|
||||
export function truncateText(
|
||||
text: string,
|
||||
viewport: Viewport,
|
||||
maxLength: { mobile?: number; tablet?: number; desktop?: number } = {}
|
||||
): string {
|
||||
const limit = viewport.isMobile
|
||||
? (maxLength.mobile ?? 100)
|
||||
: viewport.isTablet
|
||||
? (maxLength.tablet ?? 150)
|
||||
: (maxLength.desktop ?? 200);
|
||||
|
||||
return text.length > limit ? `${text.substring(0, limit)}...` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal line height based on viewport and text size
|
||||
*/
|
||||
export function getOptimalLineHeight(
|
||||
fontSize: number,
|
||||
viewport: Viewport
|
||||
): string {
|
||||
const baseLineHeight = 1.6;
|
||||
|
||||
// Tighter line height for mobile to improve readability
|
||||
if (viewport.isMobile) {
|
||||
return fontSize < 16 ? '1.5' : '1.4';
|
||||
}
|
||||
|
||||
// More breathing room for larger screens
|
||||
if (viewport.isDesktop) {
|
||||
return fontSize > 24 ? '1.3' : '1.5';
|
||||
}
|
||||
|
||||
return baseLineHeight.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive CSS custom properties
|
||||
*/
|
||||
export function generateResponsiveCSSVars(
|
||||
prefix: string,
|
||||
values: {
|
||||
mobile: Record<string, string>;
|
||||
tablet?: Record<string, string>;
|
||||
desktop?: Record<string, string>;
|
||||
}
|
||||
): string {
|
||||
const { mobile, tablet, desktop } = values;
|
||||
|
||||
let css = `:root {`;
|
||||
Object.entries(mobile).forEach(([key, value]) => {
|
||||
css += `--${prefix}-${key}: ${value};`;
|
||||
});
|
||||
css += `}`;
|
||||
|
||||
if (tablet) {
|
||||
css += `@media (min-width: ${BREAKPOINTS.md}px) { :root {`;
|
||||
Object.entries(tablet).forEach(([key, value]) => {
|
||||
css += `--${prefix}-${key}: ${value};`;
|
||||
});
|
||||
css += `} }`;
|
||||
}
|
||||
|
||||
if (desktop) {
|
||||
css += `@media (min-width: ${BREAKPOINTS.lg}px) { :root {`;
|
||||
Object.entries(desktop).forEach(([key, value]) => {
|
||||
css += `--${prefix}-${key}: ${value};`;
|
||||
});
|
||||
css += `} }`;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate responsive offset for sticky elements
|
||||
*/
|
||||
export function getStickyOffset(
|
||||
viewport: Viewport,
|
||||
elementHeight: number
|
||||
): number {
|
||||
if (viewport.isMobile) {
|
||||
return elementHeight * 0.5;
|
||||
}
|
||||
if (viewport.isTablet) {
|
||||
return elementHeight * 0.75;
|
||||
}
|
||||
return elementHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive animation duration
|
||||
*/
|
||||
export function getResponsiveDuration(
|
||||
baseDuration: number,
|
||||
viewport: Viewport
|
||||
): number {
|
||||
if (viewport.isMobile) {
|
||||
return baseDuration * 0.75; // Faster on mobile
|
||||
}
|
||||
return baseDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if viewport is in safe area for content
|
||||
*/
|
||||
export function isContentSafeArea(viewport: Viewport): boolean {
|
||||
// Ensure minimum content width for readability
|
||||
const minWidth = 320;
|
||||
return viewport.width >= minWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive form field width
|
||||
*/
|
||||
export function getFormFieldWidth(
|
||||
viewport: Viewport,
|
||||
options: { full?: boolean; half?: boolean; third?: boolean } = {}
|
||||
): string {
|
||||
if (options.full || viewport.isMobile) return '100%';
|
||||
if (options.half) return '48%';
|
||||
if (options.third) return '31%';
|
||||
|
||||
return viewport.isTablet ? '48%' : '31%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive accessibility attributes
|
||||
*/
|
||||
export function getResponsiveA11yProps(viewport: Viewport) {
|
||||
return {
|
||||
// Larger touch targets on mobile
|
||||
'aria-touch-target': viewport.isMobile ? 'large' : 'standard',
|
||||
// Mobile-optimized announcements
|
||||
'aria-mobile-optimized': viewport.isMobile ? 'true' : 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if viewport width meets minimum requirement
|
||||
*/
|
||||
export function meetsMinimumWidth(viewport: Viewport, minWidth: number): boolean {
|
||||
return viewport.width >= minWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get responsive column count for grid layouts
|
||||
*/
|
||||
export function getResponsiveColumns(viewport: Viewport): number {
|
||||
if (viewport.isMobile) return 1;
|
||||
if (viewport.isTablet) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate responsive padding based on viewport
|
||||
*/
|
||||
export function getResponsivePadding(viewport: Viewport): string {
|
||||
if (viewport.isMobile) return '1rem';
|
||||
if (viewport.isTablet) return '1.5rem';
|
||||
if (viewport.isDesktop) return '2rem';
|
||||
return '3rem';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if viewport is landscape orientation
|
||||
*/
|
||||
export function isLandscape(viewport: Viewport): boolean {
|
||||
return viewport.width > viewport.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal image quality based on viewport
|
||||
*/
|
||||
export function getOptimalImageQuality(viewport: Viewport): number {
|
||||
if (viewport.isMobile) return 75;
|
||||
if (viewport.isTablet) return 85;
|
||||
return 90;
|
||||
}
|
||||
|
||||
export default {
|
||||
BREAKPOINTS,
|
||||
getViewport,
|
||||
checkBreakpoint,
|
||||
resolveResponsiveProp,
|
||||
generateImageSizes,
|
||||
getImageDimensionsForBreakpoint,
|
||||
generateSrcset,
|
||||
isInViewport,
|
||||
clamp,
|
||||
getTouchTargetSize,
|
||||
getResponsiveSpacing,
|
||||
getResponsiveGrid,
|
||||
isTouchDevice,
|
||||
generateViewportMeta,
|
||||
truncateText,
|
||||
getOptimalLineHeight,
|
||||
generateResponsiveCSSVars,
|
||||
getStickyOffset,
|
||||
getResponsiveDuration,
|
||||
isContentSafeArea,
|
||||
getFormFieldWidth,
|
||||
getResponsiveA11yProps,
|
||||
meetsMinimumWidth,
|
||||
getResponsiveColumns,
|
||||
getResponsivePadding,
|
||||
isLandscape,
|
||||
getOptimalImageQuality,
|
||||
};
|
||||
87
lib/utils.ts
Normal file
87
lib/utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes with clsx support
|
||||
* Handles class merging, conflict resolution, and conditional classes
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to check if a value is not null or undefined
|
||||
*/
|
||||
export function isNonNullable<T>(value: T | null | undefined): value is T {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to format currency
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'EUR', locale: string = 'de-DE'): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to format date
|
||||
*/
|
||||
export function formatDate(date: Date | string, locale: string = 'de-DE'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to generate slug from text
|
||||
*/
|
||||
export function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to debounce function calls
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get initials from a name
|
||||
*/
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to truncate text
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number, suffix = '...'): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
Reference in New Issue
Block a user