wip
This commit is contained in:
@@ -139,10 +139,39 @@ function parseWPBakery(html: string): React.ReactNode[] {
|
||||
// Check for full-width background
|
||||
const isFullWidth = $row.hasClass('full-width-bg') || $row.hasClass('full-width') || $row.attr('data-full-width');
|
||||
|
||||
// Get background image from data attributes or inline styles
|
||||
const bgImage = $row.attr('data-bg-image') ||
|
||||
// Get background properties from data attributes
|
||||
const bgImage = $row.attr('data-bg-image') ||
|
||||
$row.attr('style')?.match(/background-image:\s*url\(([^)]+)\)/)?.[1] ||
|
||||
'';
|
||||
const bgColor = $row.attr('bg_color') || $row.attr('data-bg-color');
|
||||
const colorOverlay = $row.attr('color_overlay') || $row.attr('data-color-overlay');
|
||||
const overlayStrength = $row.attr('overlay_strength') || $row.attr('data-overlay-strength');
|
||||
const topPadding = $row.attr('top_padding');
|
||||
const bottomPadding = $row.attr('bottom_padding');
|
||||
const fullScreen = $row.attr('full_screen_row_position');
|
||||
|
||||
// Video background attributes - enhanced detection
|
||||
const videoMp4 = $row.attr('video_mp4') || $row.attr('data-video-mp4') ||
|
||||
$row.find('[data-video-mp4]').attr('data-video-mp4');
|
||||
const videoWebm = $row.attr('video_webm') || $row.attr('data-video-webm') ||
|
||||
$row.find('[data-video-webm]').attr('data-video-webm');
|
||||
|
||||
// Check if row has video background indicators
|
||||
const hasVideoBg = $row.attr('data-video-bg') === 'true' ||
|
||||
$row.hasClass('nectar-video-wrap') ||
|
||||
!!(videoMp4?.trim()) || !!(videoWebm?.trim());
|
||||
|
||||
// Additional WordPress Salient props
|
||||
const enableGradient = $row.attr('enable_gradient') === 'true';
|
||||
const gradientDirection = $row.attr('gradient_direction') || 'left_to_right';
|
||||
const colorOverlay2 = $row.attr('color_overlay_2');
|
||||
const parallaxBg = $row.attr('parallax_bg') === 'true';
|
||||
const parallaxBgSpeed = $row.attr('parallax_bg_speed') || 'medium';
|
||||
const bgImageAnimation = $row.attr('bg_image_animation') || 'none';
|
||||
const textAlignment = $row.attr('text_align') || 'left';
|
||||
const textColor = $row.attr('text_color') || 'dark';
|
||||
const shapeType = $row.attr('shape_type');
|
||||
const scenePosition = $row.attr('scene_position') || 'center';
|
||||
|
||||
// Get row text for pattern detection
|
||||
const rowText = $row.text();
|
||||
@@ -164,14 +193,41 @@ function parseWPBakery(html: string): React.ReactNode[] {
|
||||
$clone.find('p').first().remove();
|
||||
const remainingContent = $clone.html()?.trim();
|
||||
|
||||
// Calculate overlay opacity
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : undefined;
|
||||
|
||||
// Determine height based on full screen position
|
||||
let heroHeight: 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'screen' = isFullWidth ? 'xl' : 'md';
|
||||
if (fullScreen === 'middle' || fullScreen === 'top' || fullScreen === 'bottom') {
|
||||
heroHeight = 'screen';
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<Hero
|
||||
key={`hero-${i}`}
|
||||
title={title}
|
||||
subtitle={subtitle || undefined}
|
||||
backgroundImage={heroBg ? replaceUrlInAttribute(heroBg) : undefined}
|
||||
height={isFullWidth ? 'xl' : 'md'}
|
||||
backgroundImage={heroBg && !hasVideoBg ? replaceUrlInAttribute(heroBg) : undefined}
|
||||
height={heroHeight}
|
||||
overlay={!!heroBg}
|
||||
backgroundColor={bgColor}
|
||||
colorOverlay={colorOverlay}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
parallaxBg={parallaxBg}
|
||||
parallaxBgSpeed={parallaxBgSpeed as any}
|
||||
bgImageAnimation={bgImageAnimation as any}
|
||||
topPadding={topPadding}
|
||||
bottomPadding={bottomPadding}
|
||||
textAlignment={textAlignment as any}
|
||||
textColor={textColor as any}
|
||||
shapeType={shapeType}
|
||||
scenePosition={scenePosition as any}
|
||||
fullScreenRowPosition={fullScreen as any}
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -421,8 +477,33 @@ function parseWPBakery(html: string): React.ReactNode[] {
|
||||
const title = $h3.text().trim();
|
||||
const content = $ps.map((pIdx, pEl) => $(pEl).html() || '').get().join('<br/>');
|
||||
|
||||
// Calculate overlay opacity
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
|
||||
elements.push(
|
||||
<Section key={`content-${i}`} padding="lg">
|
||||
<Section
|
||||
key={`content-${i}`}
|
||||
padding="lg"
|
||||
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
||||
backgroundColor={bgColor}
|
||||
colorOverlay={colorOverlay}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
parallaxBg={parallaxBg}
|
||||
parallaxBgSpeed={parallaxBgSpeed as any}
|
||||
bgImageAnimation={bgImageAnimation as any}
|
||||
topPadding={topPadding}
|
||||
bottomPadding={bottomPadding}
|
||||
textAlignment={textAlignment as any}
|
||||
textColor={textColor as any}
|
||||
shapeType={shapeType}
|
||||
scenePosition={scenePosition as any}
|
||||
fullScreenRowPosition={fullScreen as any}
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-4">{title}</h3>
|
||||
<ContentRenderer content={content} parsePatterns={false} />
|
||||
</Section>
|
||||
@@ -438,7 +519,121 @@ function parseWPBakery(html: string): React.ReactNode[] {
|
||||
return;
|
||||
}
|
||||
|
||||
// PATTERN 12: Slider/Carousel (nectar_slider, vc_row with slider class)
|
||||
// PATTERN 12: Generic content row with background (no specific pattern)
|
||||
// This handles rows with backgrounds that don't match other patterns
|
||||
if (bgImage || bgColor || colorOverlay || videoMp4 || videoWebm) {
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
const innerHtml = $row.html();
|
||||
|
||||
if (innerHtml) {
|
||||
elements.push(
|
||||
<Section
|
||||
key={`bg-section-${i}`}
|
||||
padding="lg"
|
||||
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
||||
backgroundColor={bgColor}
|
||||
colorOverlay={colorOverlay}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
parallaxBg={parallaxBg}
|
||||
parallaxBgSpeed={parallaxBgSpeed as any}
|
||||
bgImageAnimation={bgImageAnimation as any}
|
||||
topPadding={topPadding}
|
||||
bottomPadding={bottomPadding}
|
||||
textAlignment={textAlignment as any}
|
||||
textColor={textColor as any}
|
||||
shapeType={shapeType}
|
||||
scenePosition={scenePosition as any}
|
||||
fullScreenRowPosition={fullScreen as any}
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
>
|
||||
<ContentRenderer content={innerHtml} parsePatterns={true} />
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 13: Buttons (vc_btn, .btn classes)
|
||||
const $buttons = $row.find('a[class*="btn"], a.vc_btn, button.vc_btn');
|
||||
if ($buttons.length > 0) {
|
||||
const buttons = $buttons.map((btnIdx, btnEl) => {
|
||||
const $btn = $(btnEl);
|
||||
const text = $btn.text().trim();
|
||||
const href = $btn.attr('href');
|
||||
const classes = $btn.attr('class') || '';
|
||||
|
||||
// Determine variant from classes
|
||||
let variant: 'primary' | 'secondary' | 'outline' | 'ghost' = 'primary';
|
||||
if (classes.includes('btn-outline') || classes.includes('vc_btn-outline')) variant = 'outline';
|
||||
if (classes.includes('btn-secondary') || classes.includes('vc_btn-secondary')) variant = 'secondary';
|
||||
if (classes.includes('btn-ghost') || classes.includes('vc_btn-ghost')) variant = 'ghost';
|
||||
|
||||
// Determine size
|
||||
let size: 'sm' | 'md' | 'lg' = 'md';
|
||||
if (classes.includes('btn-large') || classes.includes('vc_btn-lg')) size = 'lg';
|
||||
if (classes.includes('btn-small') || classes.includes('vc_btn-sm')) size = 'sm';
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`btn-${btnIdx}`}
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={() => href && (window.location.href = replaceUrlInAttribute(href))}
|
||||
className={classes.includes('btn-full') ? 'w-full' : ''}
|
||||
>
|
||||
{text || 'Click Here'}
|
||||
</Button>
|
||||
);
|
||||
}).get();
|
||||
|
||||
if (buttons.length > 0) {
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
|
||||
elements.push(
|
||||
<Section
|
||||
key={`buttons-${i}`}
|
||||
padding="lg"
|
||||
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
||||
backgroundColor={bgColor}
|
||||
colorOverlay={colorOverlay}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
parallaxBg={parallaxBg}
|
||||
parallaxBgSpeed={parallaxBgSpeed as any}
|
||||
bgImageAnimation={bgImageAnimation as any}
|
||||
topPadding={topPadding}
|
||||
bottomPadding={bottomPadding}
|
||||
textAlignment={textAlignment as any}
|
||||
textColor={textColor as any}
|
||||
shapeType={shapeType}
|
||||
scenePosition={scenePosition as any}
|
||||
fullScreenRowPosition={fullScreen as any}
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex flex-wrap gap-3',
|
||||
buttons.length > 1 ? 'justify-center' : 'justify-start'
|
||||
)}>
|
||||
{buttons}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 14: Slider/Carousel (nectar_slider, vc_row with slider class)
|
||||
if ($row.hasClass('nectar-slider') || $row.hasClass('vc_row-slider') || $row.find('.nectar-slider').length > 0) {
|
||||
const slides: Slide[] = [];
|
||||
|
||||
@@ -501,56 +696,6 @@ function parseWPBakery(html: string): React.ReactNode[] {
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 13: Buttons (vc_btn, .btn classes)
|
||||
const $buttons = $row.find('a[class*="btn"], a.vc_btn, button.vc_btn');
|
||||
if ($buttons.length > 0) {
|
||||
const buttons = $buttons.map((btnIdx, btnEl) => {
|
||||
const $btn = $(btnEl);
|
||||
const text = $btn.text().trim();
|
||||
const href = $btn.attr('href');
|
||||
const classes = $btn.attr('class') || '';
|
||||
|
||||
// Determine variant from classes
|
||||
let variant: 'primary' | 'secondary' | 'outline' | 'ghost' = 'primary';
|
||||
if (classes.includes('btn-outline') || classes.includes('vc_btn-outline')) variant = 'outline';
|
||||
if (classes.includes('btn-secondary') || classes.includes('vc_btn-secondary')) variant = 'secondary';
|
||||
if (classes.includes('btn-ghost') || classes.includes('vc_btn-ghost')) variant = 'ghost';
|
||||
|
||||
// Determine size
|
||||
let size: 'sm' | 'md' | 'lg' = 'md';
|
||||
if (classes.includes('btn-large') || classes.includes('vc_btn-lg')) size = 'lg';
|
||||
if (classes.includes('btn-small') || classes.includes('vc_btn-sm')) size = 'sm';
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`btn-${btnIdx}`}
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={() => href && (window.location.href = replaceUrlInAttribute(href))}
|
||||
className={classes.includes('btn-full') ? 'w-full' : ''}
|
||||
>
|
||||
{text || 'Click Here'}
|
||||
</Button>
|
||||
);
|
||||
}).get();
|
||||
|
||||
if (buttons.length > 0) {
|
||||
elements.push(
|
||||
<Section key={`buttons-${i}`} padding="lg">
|
||||
<div className={cn(
|
||||
'flex flex-wrap gap-3',
|
||||
buttons.length > 1 ? 'justify-center' : 'justify-start'
|
||||
)}>
|
||||
{buttons}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 14: Testimonials (quote blocks, testimonial divs)
|
||||
const hasTestimonialQuotes = rowText.includes('„') || rowText.includes('“') ||
|
||||
rowText.includes('"') || rowText.includes('Expertise') ||
|
||||
@@ -691,6 +836,389 @@ function parseWPBakery(html: string): React.ReactNode[] {
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 16: External Resource Links (vlp-link-container)
|
||||
const $vlpLinks = $row.find('.vlp-link-container');
|
||||
if ($vlpLinks.length > 0) {
|
||||
const linkCards: React.ReactNode[] = [];
|
||||
|
||||
$vlpLinks.each((linkIdx, linkEl) => {
|
||||
const $link = $(linkEl);
|
||||
const href = $link.find('a').first().attr('href') || '';
|
||||
const title = $link.find('.vlp-link-title').first().text().trim() || $link.find('a').first().text().trim();
|
||||
const summary = $link.find('.vlp-link-summary').first().text().trim();
|
||||
const imgSrc = $link.find('img').first().attr('src');
|
||||
|
||||
if (href && title) {
|
||||
linkCards.push(
|
||||
<Card key={`vlp-${linkIdx}`} variant="bordered" padding="md" hoverable>
|
||||
<a
|
||||
href={replaceUrlInAttribute(href)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block hover:no-underline"
|
||||
>
|
||||
{imgSrc && (
|
||||
<div className="mb-3">
|
||||
<FeaturedImage
|
||||
src={replaceUrlInAttribute(imgSrc)}
|
||||
alt={title}
|
||||
size="sm"
|
||||
aspectRatio="1:1"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h4 className="text-lg font-bold mb-2 text-primary">{title}</h4>
|
||||
{summary && <p className="text-sm text-gray-600">{summary}</p>}
|
||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-1">
|
||||
<span>🔗</span>
|
||||
<span className="truncate">{new URL(href).hostname}</span>
|
||||
</div>
|
||||
</a>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (linkCards.length > 0) {
|
||||
elements.push(
|
||||
<Section key={`vlp-${i}`} padding="lg">
|
||||
<h3 className="text-2xl font-bold mb-4">Related Resources</h3>
|
||||
<Grid cols={Math.min(linkCards.length, 3) as 2 | 3 | 4} gap="md">
|
||||
{linkCards}
|
||||
</Grid>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 17: Technical Specification Tables
|
||||
const $tables = $row.find('table');
|
||||
if ($tables.length > 0) {
|
||||
const tableElements: React.ReactNode[] = [];
|
||||
|
||||
$tables.each((tableIdx, tableEl) => {
|
||||
const $table = $(tableEl);
|
||||
const $rows = $table.find('tr');
|
||||
|
||||
// Check if this is a spec table (property/value format)
|
||||
const isSpecTable = $rows.length > 2 && $rows.eq(0).text().includes('Property');
|
||||
|
||||
if (isSpecTable) {
|
||||
const specs: { property: string; value: string }[] = [];
|
||||
|
||||
$rows.each((rowIdx, rowEl) => {
|
||||
if (rowIdx === 0) return; // Skip header
|
||||
const $row = $(rowEl);
|
||||
const $cells = $row.find('td');
|
||||
if ($cells.length >= 2) {
|
||||
specs.push({
|
||||
property: $cells.eq(0).text().trim(),
|
||||
value: $cells.eq(1).text().trim()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (specs.length > 0) {
|
||||
tableElements.push(
|
||||
<div key={`spec-table-${tableIdx}`} className="bg-gray-50 rounded-lg p-6 border border-gray-200">
|
||||
<h4 className="text-xl font-bold mb-4">Technical Specifications</h4>
|
||||
<dl className="space-y-3">
|
||||
{specs.map((spec, idx) => (
|
||||
<div key={idx} className="flex flex-col sm:flex-row sm:gap-4 border-b border-gray-200 pb-2 last:border-0">
|
||||
<dt className="font-semibold text-gray-700 sm:w-1/3">{spec.property}</dt>
|
||||
<dd className="text-gray-600 sm:w-2/3">{spec.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Regular table - use default HTML rendering
|
||||
const tableHtml = $table.prop('outerHTML');
|
||||
if (tableHtml) {
|
||||
tableElements.push(
|
||||
<div key={`table-${tableIdx}`} className="my-4 overflow-x-auto">
|
||||
<ContentRenderer content={tableHtml} parsePatterns={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (tableElements.length > 0) {
|
||||
elements.push(
|
||||
<Section key={`tables-${i}`} padding="lg">
|
||||
{tableElements}
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 18: FAQ Sections
|
||||
const $questions = $row.find('h3, h4').filter((idx, el) => {
|
||||
const text = $(el).text().trim();
|
||||
return text.endsWith('?') || text.toLowerCase().includes('faq') || text.toLowerCase().includes('question');
|
||||
});
|
||||
|
||||
if ($questions.length > 0) {
|
||||
const faqItems: React.ReactNode[] = [];
|
||||
|
||||
$questions.each((qIdx, qEl) => {
|
||||
const $q = $(qEl);
|
||||
const question = $q.text().trim();
|
||||
const $nextP = $q.next('p');
|
||||
const answer = $nextP.text().trim();
|
||||
|
||||
if (question && answer) {
|
||||
faqItems.push(
|
||||
<details key={`faq-${qIdx}`} className="bg-white border border-gray-200 rounded-lg p-4 my-2">
|
||||
<summary className="font-bold cursor-pointer text-primary hover:text-primary-dark">
|
||||
{question}
|
||||
</summary>
|
||||
<p className="mt-2 text-gray-700">{answer}</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (faqItems.length > 0) {
|
||||
elements.push(
|
||||
<Section key={`faq-${i}`} padding="lg">
|
||||
<h3 className="text-2xl font-bold mb-4">Frequently Asked Questions</h3>
|
||||
{faqItems}
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 19: Call-to-Action (CTA) Sections
|
||||
const $ctaText = $row.text();
|
||||
const isCTA = $ctaText.includes('👉') ||
|
||||
$ctaText.includes('Contact Us') ||
|
||||
$ctaText.includes('Get in touch') ||
|
||||
$ctaText.includes('Send your inquiry') ||
|
||||
($row.find('a[href*="contact"]').length > 0 && $row.find('h2, h3').length > 0);
|
||||
|
||||
if (isCTA && colCount <= 2) {
|
||||
const $title = $row.find('h2, h3').first();
|
||||
const $desc = $row.find('p').first();
|
||||
const $button = $row.find('a').first();
|
||||
|
||||
const title = $title.text().trim();
|
||||
const description = $desc.text().trim();
|
||||
const buttonText = $button.text().trim() || 'Contact Us';
|
||||
const buttonHref = $button.attr('href') || '/contact';
|
||||
|
||||
if (title) {
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
|
||||
elements.push(
|
||||
<Section
|
||||
key={`cta-${i}`}
|
||||
padding="xl"
|
||||
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
||||
backgroundColor={bgColor || '#1a1a1a'}
|
||||
colorOverlay={colorOverlay || '#000000'}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
parallaxBg={parallaxBg}
|
||||
parallaxBgSpeed={parallaxBgSpeed as any}
|
||||
bgImageAnimation={bgImageAnimation as any}
|
||||
textAlignment="center"
|
||||
textColor="light"
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">{title}</h2>
|
||||
{description && <p className="text-xl mb-6 opacity-90">{description}</p>}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => window.location.href = replaceUrlInAttribute(buttonHref)}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 20: Quote/Blockquote Sections
|
||||
const $blockquote = $row.find('blockquote').first();
|
||||
if ($blockquote.length > 0 && colCount === 1) {
|
||||
const quote = $blockquote.text().trim();
|
||||
const $cite = $blockquote.find('cite');
|
||||
const author = $cite.text().trim();
|
||||
|
||||
if (quote) {
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
|
||||
elements.push(
|
||||
<Section
|
||||
key={`quote-${i}`}
|
||||
padding="lg"
|
||||
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
||||
backgroundColor={bgColor}
|
||||
colorOverlay={colorOverlay}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
textAlignment="center"
|
||||
textColor={textColor as any}
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-4xl font-serif text-primary mb-4">"</div>
|
||||
<blockquote className="text-2xl md:text-3xl font-serif italic mb-4">
|
||||
{quote}
|
||||
</blockquote>
|
||||
{author && (
|
||||
<cite className="text-lg font-semibold not-italic text-gray-300">
|
||||
— {author}
|
||||
</cite>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 21: Numbered List with Icons
|
||||
const $listItems = $row.find('li');
|
||||
if ($listItems.length >= 3 && $row.find('i[class*="fa-"]').length > 0) {
|
||||
const items: React.ReactNode[] = [];
|
||||
|
||||
$listItems.each((liIdx, liEl) => {
|
||||
const $li = $(liEl);
|
||||
const $icon = $li.find('i[class*="fa-"]').first();
|
||||
const iconClass = $icon.attr('class') || '';
|
||||
const text = $li.text().trim().replace(/\s+/g, ' ');
|
||||
|
||||
if (text) {
|
||||
const iconProps = parseWpIcon(iconClass);
|
||||
items.push(
|
||||
<div key={`list-item-${liIdx}`} className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<Icon {...iconProps} className="text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
elements.push(
|
||||
<Section key={`icon-list-${i}`} padding="lg">
|
||||
<div className="space-y-3">
|
||||
{items}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 22: Video Background Row (nectar-video-wrap or data-video-bg)
|
||||
if (hasVideoBg) {
|
||||
const overlayOpacityValue = overlayStrength ? parseFloat(overlayStrength) : 0.5;
|
||||
const innerHtml = $row.html();
|
||||
|
||||
if (innerHtml) {
|
||||
elements.push(
|
||||
<Section
|
||||
key={`video-bg-${i}`}
|
||||
padding="lg"
|
||||
backgroundImage={bgImage && !hasVideoBg ? replaceUrlInAttribute(bgImage) : undefined}
|
||||
backgroundColor={bgColor}
|
||||
colorOverlay={colorOverlay}
|
||||
overlayOpacity={overlayOpacityValue}
|
||||
enableGradient={enableGradient}
|
||||
gradientDirection={gradientDirection as any}
|
||||
colorOverlay2={colorOverlay2}
|
||||
parallaxBg={parallaxBg}
|
||||
parallaxBgSpeed={parallaxBgSpeed as any}
|
||||
bgImageAnimation={bgImageAnimation as any}
|
||||
topPadding={topPadding}
|
||||
bottomPadding={bottomPadding}
|
||||
textAlignment={textAlignment as any}
|
||||
textColor={textColor as any}
|
||||
shapeType={shapeType}
|
||||
scenePosition={scenePosition as any}
|
||||
fullScreenRowPosition={fullScreen as any}
|
||||
videoMp4={videoMp4 ? replaceUrlInAttribute(videoMp4) : undefined}
|
||||
videoWebm={videoWebm ? replaceUrlInAttribute(videoWebm) : undefined}
|
||||
>
|
||||
<ContentRenderer content={innerHtml} parsePatterns={true} />
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PATTERN 23: Video Embed with Text
|
||||
const $video = $row.find('video').first();
|
||||
if ($video.length > 0 && colCount === 1) {
|
||||
const videoSrc = $video.attr('src') || $video.find('source').first().attr('src');
|
||||
const $title = $row.find('h2, h3').first();
|
||||
const $desc = $row.find('p').first();
|
||||
|
||||
if (videoSrc) {
|
||||
elements.push(
|
||||
<Section key={`video-embed-${i}`} padding="lg">
|
||||
<div className="grid md:grid-cols-2 gap-6 items-center">
|
||||
<div>
|
||||
<video
|
||||
controls
|
||||
className="w-full rounded-lg shadow-lg"
|
||||
style={{ opacity: 1 }}
|
||||
poster={replaceUrlInAttribute($video.attr('poster') || '')}
|
||||
>
|
||||
<source src={replaceUrlInAttribute(videoSrc)} type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
<div>
|
||||
{$title.length > 0 && <h3 className="text-2xl font-bold mb-3">{$title.text().trim()}</h3>}
|
||||
{$desc.length > 0 && <ContentRenderer content={$desc.html() || ''} parsePatterns={false} />}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
$row.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Generic section with nested content
|
||||
const innerHtml = $row.html();
|
||||
if (innerHtml) {
|
||||
@@ -925,7 +1453,7 @@ function parseHTMLToReact(html: string): React.ReactNode {
|
||||
|
||||
// Get sources
|
||||
const sources: React.ReactNode[] = [];
|
||||
Array.from(element.childNodes).forEach((child, i) => {
|
||||
Array.from(node.childNodes).forEach((child, i) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === 'source') {
|
||||
const sourceEl = child as HTMLSourceElement;
|
||||
// Replace asset URLs in source src
|
||||
@@ -950,6 +1478,10 @@ function parseHTMLToReact(html: string): React.ReactNode {
|
||||
videoProps.poster = replaceUrlInAttribute(element.getAttribute('poster'));
|
||||
}
|
||||
|
||||
// Ensure video is always fully visible
|
||||
if (!videoProps.style) videoProps.style = {};
|
||||
videoProps.style.opacity = 1;
|
||||
|
||||
return (
|
||||
<video {...videoProps}>
|
||||
{sources}
|
||||
@@ -1004,6 +1536,7 @@ function parseHTMLToReact(html: string): React.ReactNode {
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{mp4 && <source src={replaceUrlInAttribute(mp4)} type="video/mp4" />}
|
||||
{webm && <source src={replaceUrlInAttribute(webm)} type="video/webm" />}
|
||||
@@ -1166,19 +1699,20 @@ function replaceWordPressAssets(html: string): string {
|
||||
|
||||
/**
|
||||
* Convert WordPress/Salient classes to Tailwind equivalents
|
||||
* Note: vc-row and vc-column classes are preserved for pattern parsing
|
||||
*/
|
||||
function convertWordPressClasses(html: string): string {
|
||||
const classMap: Record<string, string> = {
|
||||
// Salient/Vc_row classes
|
||||
'vc_row': 'flex flex-wrap -mx-4',
|
||||
'vc_row-fluid': 'w-full',
|
||||
'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',
|
||||
// Salient/Vc_row classes - PRESERVED for pattern parsing
|
||||
// 'vc-row': 'flex flex-wrap -mx-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_row-fluid': 'w-full', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-sm-12': 'w-full px-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-md-6': 'w-full md:w-1/2 px-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-md-4': 'w-full md:w-1/3 px-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-md-3': 'w-full md:w-1/4 px-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-lg-6': 'w-full lg:w-1/2 px-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-lg-4': 'w-full lg:w-1/3 px-4', // REMOVED - handled by parseWPBakery
|
||||
// 'vc_col-lg-3': 'w-full lg:w-1/4 px-4', // REMOVED - handled by parseWPBakery
|
||||
|
||||
// Typography
|
||||
'wpb_wrapper': 'space-y-4',
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Container } from '../ui/Container';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
// Hero height options
|
||||
type HeroHeight = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
type HeroHeight = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'screen';
|
||||
|
||||
// Hero variant options
|
||||
type HeroVariant = 'default' | 'dark' | 'primary' | 'gradient';
|
||||
@@ -24,24 +26,51 @@ interface HeroProps {
|
||||
overlayOpacity?: number;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
// Additional props for background color and overlay
|
||||
backgroundColor?: string;
|
||||
colorOverlay?: string;
|
||||
overlayStrength?: number;
|
||||
// WordPress Salient-specific props
|
||||
enableGradient?: boolean;
|
||||
gradientDirection?: 'left_to_right' | 'right_to_left' | 'top_to_bottom' | 'bottom_to_top';
|
||||
colorOverlay2?: string;
|
||||
parallaxBg?: boolean;
|
||||
parallaxBgSpeed?: 'slow' | 'fast' | 'medium';
|
||||
bgImageAnimation?: 'none' | 'zoom-out-reveal' | 'fade-in';
|
||||
topPadding?: string;
|
||||
bottomPadding?: string;
|
||||
textAlignment?: 'left' | 'center' | 'right';
|
||||
textColor?: 'light' | 'dark';
|
||||
shapeType?: string;
|
||||
scenePosition?: 'center' | 'top' | 'bottom';
|
||||
fullScreenRowPosition?: 'middle' | 'top' | 'bottom';
|
||||
// Video background props
|
||||
videoBg?: string;
|
||||
videoMp4?: string;
|
||||
videoWebm?: string;
|
||||
}
|
||||
|
||||
// Helper function to get height styles
|
||||
const getHeightStyles = (height: HeroHeight) => {
|
||||
switch (height) {
|
||||
case 'sm':
|
||||
return 'min-h-[300px] md:min-h-[400px]';
|
||||
case 'md':
|
||||
return 'min-h-[400px] md:min-h-[500px]';
|
||||
case 'lg':
|
||||
return 'min-h-[500px] md:min-h-[600px]';
|
||||
case 'xl':
|
||||
return 'min-h-[600px] md:min-h-[700px]';
|
||||
case 'full':
|
||||
return 'min-h-screen';
|
||||
default:
|
||||
return 'min-h-[500px] md:min-h-[600px]';
|
||||
const getHeightStyles = (height: HeroHeight, fullScreenRowPosition?: string) => {
|
||||
const baseHeight = {
|
||||
sm: 'min-h-[300px] md:min-h-[400px]',
|
||||
md: 'min-h-[400px] md:min-h-[500px]',
|
||||
lg: 'min-h-[500px] md:min-h-[600px]',
|
||||
xl: 'min-h-[600px] md:min-h-[700px]',
|
||||
full: 'min-h-screen',
|
||||
screen: 'min-h-screen'
|
||||
}[height] || 'min-h-[500px] md:min-h-[600px]';
|
||||
|
||||
// Handle full screen positioning
|
||||
if (fullScreenRowPosition === 'middle') {
|
||||
return `${baseHeight} flex items-center justify-center`;
|
||||
} else if (fullScreenRowPosition === 'top') {
|
||||
return `${baseHeight} items-start justify-center pt-12`;
|
||||
} else if (fullScreenRowPosition === 'bottom') {
|
||||
return `${baseHeight} items-end justify-center pb-12`;
|
||||
}
|
||||
|
||||
return baseHeight;
|
||||
};
|
||||
|
||||
// Helper function to get variant styles
|
||||
@@ -80,57 +109,241 @@ export const Hero: React.FC<HeroProps> = ({
|
||||
overlayOpacity,
|
||||
children,
|
||||
className = '',
|
||||
backgroundColor,
|
||||
colorOverlay,
|
||||
overlayStrength,
|
||||
enableGradient = false,
|
||||
gradientDirection = 'left_to_right',
|
||||
colorOverlay2,
|
||||
parallaxBg = false,
|
||||
parallaxBgSpeed = 'medium',
|
||||
bgImageAnimation = 'none',
|
||||
topPadding,
|
||||
bottomPadding,
|
||||
textAlignment = 'center',
|
||||
textColor = 'light',
|
||||
shapeType,
|
||||
scenePosition = 'center',
|
||||
fullScreenRowPosition,
|
||||
videoBg,
|
||||
videoMp4,
|
||||
videoWebm,
|
||||
}) => {
|
||||
const hasBackground = !!backgroundImage;
|
||||
const hasCTA = !!ctaText && !!ctaLink;
|
||||
const hasColorOverlay = !!colorOverlay;
|
||||
const hasGradient = !!enableGradient;
|
||||
const hasParallax = !!parallaxBg;
|
||||
const hasVideo = !!(videoMp4?.trim()) || !!(videoWebm?.trim());
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Calculate overlay opacity
|
||||
const overlayOpacityValue = overlayOpacity ?? (overlayStrength !== undefined ? overlayStrength : 0.5);
|
||||
|
||||
// Get text alignment
|
||||
const textAlignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[textAlignment];
|
||||
|
||||
// Get text color
|
||||
const textColorClass = textColor === 'light' ? 'text-white' : 'text-gray-900';
|
||||
const subtitleTextColorClass = textColor === 'light' ? 'text-gray-100' : 'text-gray-600';
|
||||
|
||||
// Get gradient direction
|
||||
const gradientDirectionClass = {
|
||||
'left_to_right': 'bg-gradient-to-r',
|
||||
'right_to_left': 'bg-gradient-to-l',
|
||||
'top_to_bottom': 'bg-gradient-to-b',
|
||||
'bottom_to_top': 'bg-gradient-to-t',
|
||||
}[gradientDirection];
|
||||
|
||||
// Get parallax speed
|
||||
const parallaxSpeedClass = {
|
||||
slow: 'parallax-slow',
|
||||
medium: 'parallax-medium',
|
||||
fast: 'parallax-fast',
|
||||
}[parallaxBgSpeed];
|
||||
|
||||
// Get background animation
|
||||
const bgAnimationClass = {
|
||||
none: '',
|
||||
'zoom-out-reveal': 'animate-zoom-out',
|
||||
'fade-in': 'animate-fade-in',
|
||||
}[bgImageAnimation];
|
||||
|
||||
// Calculate padding from props
|
||||
const customPaddingStyle = {
|
||||
paddingTop: topPadding || undefined,
|
||||
paddingBottom: bottomPadding || undefined,
|
||||
};
|
||||
|
||||
// Parallax effect handler
|
||||
useEffect(() => {
|
||||
if (!hasParallax || !heroRef.current) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!heroRef.current) return;
|
||||
|
||||
const rect = heroRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate offset based on scroll position
|
||||
const scrollProgress = (viewportHeight - rect.top) / (viewportHeight + rect.height);
|
||||
const offset = scrollProgress * 50; // Max 50px offset
|
||||
|
||||
// Apply to CSS variable
|
||||
heroRef.current.style.setProperty('--parallax-offset', `${offset}px`);
|
||||
};
|
||||
|
||||
handleScroll(); // Initial call
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [hasParallax]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden flex items-center justify-center',
|
||||
getHeightStyles(height),
|
||||
'relative w-full overflow-hidden',
|
||||
getHeightStyles(height, fullScreenRowPosition),
|
||||
textAlignClass,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
...customPaddingStyle,
|
||||
}}
|
||||
>
|
||||
{/* Background Image */}
|
||||
{hasBackground && (
|
||||
{/* Video Background */}
|
||||
{hasVideo && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{videoWebm && <source src={videoWebm} type="video/webm" />}
|
||||
{videoMp4 && <source src={videoMp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image with Parallax (fallback if no video) */}
|
||||
{hasBackground && !hasVideo && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
hasParallax && parallaxSpeedClass,
|
||||
bgAnimationClass
|
||||
)}>
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
className={cn(
|
||||
'object-cover',
|
||||
hasParallax && 'transform-gpu'
|
||||
)}
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackground && (
|
||||
{!hasBackground && !backgroundColor && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getVariantStyles(variant)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{overlay && hasBackground && (
|
||||
{/* Gradient Overlay */}
|
||||
{hasGradient && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
gradientDirectionClass,
|
||||
'from-transparent via-transparent to-transparent'
|
||||
)}
|
||||
style={{
|
||||
opacity: overlayOpacityValue * 0.3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Overlay (from WordPress color_overlay) */}
|
||||
{hasColorOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay,
|
||||
opacity: overlayOpacityValue
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Second Color Overlay (for gradients) */}
|
||||
{colorOverlay2 && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay2,
|
||||
opacity: overlayOpacityValue * 0.5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Standard Overlay */}
|
||||
{overlay && hasBackground && !hasColorOverlay && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
getOverlayOpacity(overlayOpacity)
|
||||
getOverlayOpacity(overlayOpacityValue)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Shape Divider (bottom) */}
|
||||
{shapeType && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg
|
||||
className="w-full h-16 md:h-24 lg:h-32"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapeType === 'waves_opacity_alt' && (
|
||||
<path
|
||||
d="M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.8,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{shapeType === 'mountains' && (
|
||||
<path
|
||||
d="M0,0V60c100,0,150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-20 w-full">
|
||||
<Container
|
||||
maxWidth="6xl"
|
||||
padding="lg"
|
||||
padding="none"
|
||||
className={cn(
|
||||
'text-center',
|
||||
'px-4 sm:px-6 md:px-8',
|
||||
// Add padding for full-height heroes
|
||||
height === 'full' && 'py-12 md:py-20'
|
||||
height === 'full' || height === 'screen' ? 'py-12 md:py-20' : 'py-8 md:py-12'
|
||||
)}
|
||||
>
|
||||
{/* Title */}
|
||||
@@ -139,8 +352,9 @@ export const Hero: React.FC<HeroProps> = ({
|
||||
'font-bold leading-tight mb-4',
|
||||
'text-3xl sm:text-4xl md:text-5xl lg:text-6xl',
|
||||
'tracking-tight',
|
||||
// Ensure text contrast
|
||||
hasBackground || variant !== 'default' ? 'text-white' : 'text-gray-900'
|
||||
textColorClass,
|
||||
// Enhanced contrast for overlays
|
||||
(hasBackground || hasColorOverlay || variant !== 'default') && 'drop-shadow-lg'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
@@ -153,7 +367,8 @@ export const Hero: React.FC<HeroProps> = ({
|
||||
'text-lg sm:text-xl md:text-2xl',
|
||||
'mb-8 max-w-3xl mx-auto',
|
||||
'leading-relaxed',
|
||||
hasBackground || variant !== 'default' ? 'text-gray-100' : 'text-gray-600'
|
||||
subtitleTextColorClass,
|
||||
(hasBackground || hasColorOverlay || variant !== 'default') && 'drop-shadow-md'
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Container } from '../ui/Container';
|
||||
|
||||
@@ -16,6 +19,33 @@ interface SectionProps {
|
||||
className?: string;
|
||||
id?: string;
|
||||
as?: React.ElementType;
|
||||
// Additional props for background images and overlays
|
||||
backgroundImage?: string;
|
||||
backgroundAlt?: string;
|
||||
colorOverlay?: string;
|
||||
overlayOpacity?: number;
|
||||
backgroundColor?: string;
|
||||
// WordPress Salient-specific props
|
||||
enableGradient?: boolean;
|
||||
gradientDirection?: 'left_to_right' | 'right_to_left' | 'top_to_bottom' | 'bottom_to_top';
|
||||
colorOverlay2?: string;
|
||||
parallaxBg?: boolean;
|
||||
parallaxBgSpeed?: 'slow' | 'fast' | 'medium';
|
||||
bgImageAnimation?: 'none' | 'zoom-out-reveal' | 'fade-in';
|
||||
topPadding?: string;
|
||||
bottomPadding?: string;
|
||||
textAlignment?: 'left' | 'center' | 'right';
|
||||
textColor?: 'light' | 'dark';
|
||||
shapeType?: string;
|
||||
scenePosition?: 'center' | 'top' | 'bottom';
|
||||
fullScreenRowPosition?: 'middle' | 'top' | 'bottom';
|
||||
// Additional styling
|
||||
borderRadius?: string;
|
||||
boxShadow?: boolean;
|
||||
// Video background props
|
||||
videoBg?: string;
|
||||
videoMp4?: string;
|
||||
videoWebm?: string;
|
||||
}
|
||||
|
||||
// Helper function to get background styles
|
||||
@@ -64,32 +94,362 @@ export const Section: React.FC<SectionProps> = ({
|
||||
className = '',
|
||||
id,
|
||||
as: Component = 'section',
|
||||
backgroundImage,
|
||||
backgroundAlt = '',
|
||||
colorOverlay,
|
||||
overlayOpacity = 0.5,
|
||||
backgroundColor,
|
||||
enableGradient = false,
|
||||
gradientDirection = 'left_to_right',
|
||||
colorOverlay2,
|
||||
parallaxBg = false,
|
||||
parallaxBgSpeed = 'medium',
|
||||
bgImageAnimation = 'none',
|
||||
topPadding,
|
||||
bottomPadding,
|
||||
textAlignment = 'left',
|
||||
textColor = 'dark',
|
||||
shapeType,
|
||||
scenePosition = 'center',
|
||||
fullScreenRowPosition,
|
||||
borderRadius,
|
||||
boxShadow = false,
|
||||
videoBg,
|
||||
videoMp4,
|
||||
videoWebm,
|
||||
}) => {
|
||||
const sectionClasses = cn(
|
||||
'w-full',
|
||||
getBackgroundStyles(background),
|
||||
const hasBackgroundImage = !!backgroundImage;
|
||||
const hasColorOverlay = !!colorOverlay;
|
||||
const hasCustomBg = !!backgroundColor;
|
||||
const hasGradient = !!enableGradient;
|
||||
const hasParallax = !!parallaxBg;
|
||||
const hasVideo = !!(videoMp4?.trim()) || !!(videoWebm?.trim());
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Get text alignment
|
||||
const textAlignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[textAlignment];
|
||||
|
||||
// Get text color
|
||||
const textColorClass = textColor === 'light' ? 'text-white' : 'text-gray-900';
|
||||
|
||||
// Get gradient direction
|
||||
const gradientDirectionClass = {
|
||||
'left_to_right': 'bg-gradient-to-r',
|
||||
'right_to_left': 'bg-gradient-to-l',
|
||||
'top_to_bottom': 'bg-gradient-to-b',
|
||||
'bottom_to_top': 'bg-gradient-to-t',
|
||||
}[gradientDirection];
|
||||
|
||||
// Get parallax speed
|
||||
const parallaxSpeedClass = {
|
||||
slow: 'parallax-slow',
|
||||
medium: 'parallax-medium',
|
||||
fast: 'parallax-fast',
|
||||
}[parallaxBgSpeed];
|
||||
|
||||
// Get background animation
|
||||
const bgAnimationClass = {
|
||||
none: '',
|
||||
'zoom-out-reveal': 'animate-zoom-out',
|
||||
'fade-in': 'animate-fade-in',
|
||||
}[bgImageAnimation];
|
||||
|
||||
// Calculate padding from props
|
||||
const customPaddingStyle = {
|
||||
paddingTop: topPadding || undefined,
|
||||
paddingBottom: bottomPadding || undefined,
|
||||
};
|
||||
|
||||
// Base classes
|
||||
const baseClasses = cn(
|
||||
'w-full relative overflow-hidden',
|
||||
getPaddingStyles(padding),
|
||||
textAlignClass,
|
||||
textColorClass,
|
||||
boxShadow && 'shadow-xl',
|
||||
borderRadius && `rounded-${borderRadius}`,
|
||||
className
|
||||
);
|
||||
|
||||
const content = fullWidth ? (
|
||||
<div className={sectionClasses} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<section className={sectionClasses} id={id}>
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
{children}
|
||||
</Container>
|
||||
</section>
|
||||
// Background style (for solid colors)
|
||||
const backgroundStyle = hasCustomBg ? { backgroundColor, ...customPaddingStyle } : customPaddingStyle;
|
||||
|
||||
// Content wrapper classes
|
||||
const contentWrapperClasses = cn(
|
||||
'relative z-20 w-full',
|
||||
!fullWidth && 'container mx-auto px-4 md:px-6'
|
||||
);
|
||||
|
||||
if (Component !== 'section' && !fullWidth) {
|
||||
// Parallax effect handler
|
||||
useEffect(() => {
|
||||
if (!hasParallax || !sectionRef.current) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!sectionRef.current) return;
|
||||
|
||||
const rect = sectionRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate offset based on scroll position
|
||||
const scrollProgress = (viewportHeight - rect.top) / (viewportHeight + rect.height);
|
||||
const offset = scrollProgress * 50; // Max 50px offset
|
||||
|
||||
// Apply to CSS variable
|
||||
sectionRef.current.style.setProperty('--parallax-offset', `${offset}px`);
|
||||
};
|
||||
|
||||
handleScroll(); // Initial call
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [hasParallax]);
|
||||
|
||||
const content = (
|
||||
<div ref={sectionRef} className={baseClasses} id={id} style={backgroundStyle}>
|
||||
{/* Video Background */}
|
||||
{hasVideo && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{videoWebm && <source src={videoWebm} type="video/webm" />}
|
||||
{videoMp4 && <source src={videoMp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image with Parallax (fallback if no video) */}
|
||||
{hasBackgroundImage && !hasVideo && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
hasParallax && parallaxSpeedClass,
|
||||
bgAnimationClass
|
||||
)}>
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || ''}
|
||||
fill
|
||||
className={cn(
|
||||
'object-cover',
|
||||
hasParallax && 'transform-gpu'
|
||||
)}
|
||||
sizes="100vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackgroundImage && !hasCustomBg && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getBackgroundStyles(background)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
{hasGradient && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
gradientDirectionClass,
|
||||
'from-transparent via-transparent to-transparent'
|
||||
)}
|
||||
style={{
|
||||
opacity: overlayOpacity * 0.3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Overlay (from WordPress color_overlay) */}
|
||||
{hasColorOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay,
|
||||
opacity: overlayOpacity
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Second Color Overlay (for gradients) */}
|
||||
{colorOverlay2 && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay2,
|
||||
opacity: overlayOpacity * 0.5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shape Divider (bottom) */}
|
||||
{shapeType && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg
|
||||
className="w-full h-16 md:h-24 lg:h-32"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapeType === 'waves_opacity_alt' && (
|
||||
<path
|
||||
d="M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.8,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{shapeType === 'mountains' && (
|
||||
<path
|
||||
d="M0,0V60c100,0,150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={contentWrapperClasses}>
|
||||
{fullWidth ? children : (
|
||||
<Container maxWidth="6xl" padding="none">
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (Component !== 'section') {
|
||||
return (
|
||||
<Component className={sectionClasses} id={id}>
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
{children}
|
||||
</Container>
|
||||
<Component className={baseClasses} id={id} style={backgroundStyle}>
|
||||
{/* Video Background */}
|
||||
{hasVideo && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{videoWebm && <source src={videoWebm} type="video/webm" />}
|
||||
{videoMp4 && <source src={videoMp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image with Parallax (fallback if no video) */}
|
||||
{hasBackgroundImage && !hasVideo && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
hasParallax && parallaxSpeedClass,
|
||||
bgAnimationClass
|
||||
)}>
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || ''}
|
||||
fill
|
||||
className={cn(
|
||||
'object-cover',
|
||||
hasParallax && 'transform-gpu'
|
||||
)}
|
||||
sizes="100vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackgroundImage && !hasCustomBg && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getBackgroundStyles(background)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
{hasGradient && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
gradientDirectionClass,
|
||||
'from-transparent via-transparent to-transparent'
|
||||
)}
|
||||
style={{
|
||||
opacity: overlayOpacity * 0.3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Overlay */}
|
||||
{hasColorOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay,
|
||||
opacity: overlayOpacity
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Second Color Overlay */}
|
||||
{colorOverlay2 && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay2,
|
||||
opacity: overlayOpacity * 0.5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shape Divider */}
|
||||
{shapeType && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg
|
||||
className="w-full h-16 md:h-24 lg:h-32"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapeType === 'waves_opacity_alt' && (
|
||||
<path
|
||||
d="M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.8,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{shapeType === 'mountains' && (
|
||||
<path
|
||||
d="M0,0V60c100,0,150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={contentWrapperClasses}>
|
||||
{fullWidth ? children : (
|
||||
<Container maxWidth="6xl" padding="none">
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,33 +13,47 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export function Header({ locale, siteName = 'KLZ Cables', logo }: HeaderProps) {
|
||||
const isSvgLogo = logo?.endsWith('.svg');
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200 shadow-sm">
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo and Branding */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{logo ? (
|
||||
<div className="relative h-8 w-auto">
|
||||
<Image
|
||||
src={logo.replace(/^\//, '')}
|
||||
alt={siteName}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 768px) 100vw, 120px"
|
||||
priority={false}
|
||||
/>
|
||||
<div className="h-8 sm:h-10 md:h-12 w-auto flex items-center justify-center">
|
||||
{isSvgLogo ? (
|
||||
// For SVG, use img tag with proper path handling
|
||||
<img
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
className="h-full w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
// For other images, use Next.js Image with optimized sizes
|
||||
<div className="relative h-8 sm:h-10 md:h-12 w-auto">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 120px, 144px"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">KLZ</span>
|
||||
<div className="w-8 sm:w-10 md:w-12 h-8 sm:h-10 md:h-12 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xs sm:text-sm">KLZ</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:block font-bold text-lg text-gray-900">
|
||||
<span className="hidden sm:block font-bold text-lg md:text-xl text-gray-900">
|
||||
{siteName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -16,15 +16,8 @@ interface MobileMenuProps {
|
||||
|
||||
export function MobileMenu({ locale, siteName, logo, onClose }: MobileMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Close menu when route changes
|
||||
|
||||
// Main navigation menu
|
||||
const mainMenu = [
|
||||
{ title: 'Home', path: `/${locale}` },
|
||||
@@ -56,6 +49,8 @@ export function MobileMenu({ locale, siteName, logo, onClose }: MobileMenuProps)
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
const isSvgLogo = logo?.endsWith('.svg');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Toggle Button */}
|
||||
@@ -102,15 +97,25 @@ export function MobileMenu({ locale, siteName, logo, onClose }: MobileMenuProps)
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 safe-area-p">
|
||||
<div className="flex items-center gap-3">
|
||||
{logo ? (
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src={logo.replace(/^\//, '')}
|
||||
alt={siteName}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="40px"
|
||||
priority={false}
|
||||
/>
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
{isSvgLogo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="40px"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
@@ -215,17 +220,17 @@ export function MobileMenu({ locale, siteName, logo, onClose }: MobileMenuProps)
|
||||
</div>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<Link href={`/${locale}/contact`} onClick={closeMenu} className="block w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
>
|
||||
Get in Touch
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<Link href={`/${locale}/contact`} onClick={closeMenu} className="block w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
>
|
||||
Get in Touch
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -149,16 +149,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
const responsiveSizeValue = getResponsiveSize();
|
||||
|
||||
// Get touch target size
|
||||
// Get touch target size - fixed for hydration
|
||||
const getTouchTargetClasses = () => {
|
||||
if (!touchTarget) return '';
|
||||
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
const viewport = getViewport();
|
||||
const targetSize = getTouchTargetSize(viewport.isMobile, viewport.isLargeDesktop);
|
||||
|
||||
// Ensure minimum touch target
|
||||
// Always return the same classes to avoid hydration mismatch
|
||||
// The touch target is a design requirement that should be consistent
|
||||
return `min-h-[44px] min-w-[44px]`;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user