migration wip

This commit is contained in:
2025-12-29 18:18:48 +01:00
parent 292975299d
commit f86785bfb0
182 changed files with 30131 additions and 9321 deletions

View File

@@ -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();
}

View File

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