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

@@ -0,0 +1,243 @@
import React from 'react';
import Link from 'next/link';
import { cn } from '../../lib/utils';
import { Container } from '../ui/Container';
interface BreadcrumbItem {
label: string;
href?: string;
icon?: React.ReactNode;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
separator?: React.ReactNode;
className?: string;
showHome?: boolean;
homeLabel?: string;
homeHref?: string;
collapseMobile?: boolean;
}
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
items,
separator = '/',
className = '',
showHome = true,
homeLabel = 'Home',
homeHref = '/',
collapseMobile = true,
}) => {
// Generate schema.org structured data
const generateSchema = () => {
const breadcrumbs = [
...(showHome ? [{ label: homeLabel, href: homeHref }] : []),
...items,
].map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.label,
...(item.href && { item: item.href }),
}));
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbs,
};
};
// Render individual breadcrumb item
const renderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
const isHome = showHome && index === 0 && item.href === homeHref;
const content = (
<span className="flex items-center gap-2">
{isHome && (
<span className="inline-flex items-center justify-center w-4 h-4">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
</span>
)}
{item.icon && <span className="inline-flex items-center">{item.icon}</span>}
<span className={cn(
'transition-colors duration-200',
isLast ? 'font-semibold text-gray-900' : 'text-gray-600 hover:text-gray-900'
)}>
{item.label}
</span>
</span>
);
if (!isLast && item.href) {
return (
<Link
href={item.href}
className={cn(
'inline-flex items-center gap-2',
'transition-colors duration-200',
'hover:text-primary'
)}
>
{content}
</Link>
);
}
return content;
};
const allItems = [
...(showHome ? [{ label: homeLabel, href: homeHref }] : []),
...items,
];
// Schema.org JSON-LD
const schema = generateSchema();
return (
<>
{/* Schema.org structured data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
{/* Breadcrumbs navigation */}
<nav
aria-label="Breadcrumb"
className={cn('w-full bg-gray-50 py-3', className)}
>
<Container maxWidth="6xl" padding="md">
<ol className={cn(
'flex items-center flex-wrap gap-2 text-sm',
// Mobile: show only current and parent, hide others
collapseMobile && 'max-w-full overflow-hidden'
)}>
{allItems.map((item, index) => {
const isLast = index === allItems.length - 1;
const isFirst = index === 0;
return (
<li
key={index}
className={cn(
'flex items-center gap-2',
// On mobile, hide intermediate items but keep structure
collapseMobile && !isFirst && !isLast && 'hidden sm:flex'
)}
>
{renderItem(item, index, isLast)}
{!isLast && (
<span className={cn(
'text-gray-400 select-none',
// Use different separator for mobile vs desktop
'hidden sm:inline'
)}>
{separator}
</span>
)}
</li>
);
})}
</ol>
</Container>
</nav>
</>
);
};
// Sub-components for variations
export const BreadcrumbsCompact: React.FC<Omit<BreadcrumbsProps, 'collapseMobile'>> = ({
items,
separator = '',
className = '',
showHome = true,
homeLabel = 'Home',
homeHref = '/',
}) => {
const allItems = [
...(showHome ? [{ label: homeLabel, href: homeHref }] : []),
...items,
];
return (
<nav aria-label="Breadcrumb" className={cn('w-full', className)}>
<Container maxWidth="6xl" padding="md">
<ol className="flex items-center gap-2 text-xs md:text-sm">
{allItems.map((item, index) => {
const isLast = index === allItems.length - 1;
return (
<li key={index} className="flex items-center gap-2">
{item.href && !isLast ? (
<Link
href={item.href}
className="text-gray-500 hover:text-gray-900 transition-colors"
>
{item.label}
</Link>
) : (
<span className={cn(
isLast ? 'font-medium text-gray-900' : 'text-gray-500'
)}>
{item.label}
</span>
)}
{!isLast && (
<span className="text-gray-400">{separator}</span>
)}
</li>
);
})}
</ol>
</Container>
</nav>
);
};
export const BreadcrumbsSimple: React.FC<{
items: BreadcrumbItem[];
className?: string;
}> = ({ items, className = '' }) => {
return (
<nav aria-label="Breadcrumb" className={cn('w-full', className)}>
<ol className="flex items-center gap-2 text-sm">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="flex items-center gap-2">
{item.href && !isLast ? (
<Link
href={item.href}
className="text-blue-600 hover:text-blue-800 hover:underline transition-colors"
>
{item.label}
</Link>
) : (
<span className={cn(
isLast ? 'font-semibold text-gray-900' : 'text-gray-600'
)}>
{item.label}
</span>
)}
{!isLast && <span className="text-gray-400">/</span>}
</li>
);
})}
</ol>
</nav>
);
};
export default Breadcrumbs;

View File

@@ -0,0 +1,402 @@
/**
* Content Components Example
* Demonstrates how to use the content components with WordPress data
*/
import React from 'react';
import {
Hero,
Section,
SectionHeader,
SectionContent,
SectionGrid,
FeaturedImage,
ContentRenderer,
Breadcrumbs,
ContentBlock,
RichText,
Avatar,
ImageGallery,
} from './index';
import { Button, Card, CardHeader, CardBody, CardFooter, Grid } from '../ui';
import { Page, Post, Product, getMediaById } from '../../lib/data';
// Example: Hero component with WordPress data
export const ExampleHero: React.FC<{
page: Page;
}> = ({ page }) => {
const featuredMedia = page.featuredImage ? getMediaById(page.featuredImage) : null;
return (
<Hero
title={page.title}
subtitle={page.excerptHtml ? page.excerptHtml.replace(/<[^>]*>/g, '') : undefined}
backgroundImage={featuredMedia?.localPath}
backgroundAlt={featuredMedia?.alt || page.title}
height="lg"
variant="dark"
overlay={true}
overlayOpacity={0.6}
ctaText="Learn More"
ctaLink="#content"
/>
);
};
// Example: Section component for content blocks
export const ExampleSection: React.FC<{
title: string;
content: string;
background?: 'default' | 'light' | 'dark';
}> = ({ title, content, background = 'default' }) => {
return (
<Section background={background} padding="xl">
<SectionHeader title={title} align="center" />
<SectionContent>
<ContentRenderer content={content} />
</SectionContent>
</Section>
);
};
// Example: Featured content grid
export const ExampleContentGrid: React.FC<{
posts: Post[];
}> = ({ posts }) => {
return (
<Section background="light" padding="xl">
<SectionHeader
title="Latest News"
subtitle="Stay updated with our latest developments"
align="center"
/>
<SectionGrid cols={3} gap="md">
{posts.map((post) => {
const featuredMedia = post.featuredImage ? getMediaById(post.featuredImage) : null;
return (
<Card key={post.id} variant="elevated">
{featuredMedia && (
<div className="relative h-48">
<FeaturedImage
src={featuredMedia.localPath}
alt={featuredMedia.alt || post.title}
size="full"
aspectRatio="16:9"
/>
</div>
)}
<CardHeader>
<h3 className="text-xl font-bold">{post.title}</h3>
<small className="text-gray-500">
{new Date(post.datePublished).toLocaleDateString()}
</small>
</CardHeader>
<CardBody>
<RichText
html={post.excerptHtml}
className="text-gray-600 line-clamp-3"
/>
</CardBody>
<CardFooter>
<Button variant="primary" size="sm">
Read More
</Button>
</CardFooter>
</Card>
);
})}
</SectionGrid>
</Section>
);
};
// Example: Product showcase
export const ExampleProductShowcase: React.FC<{
product: Product;
}> = ({ product }) => {
const images = product.images.map((img) => ({
src: img,
alt: product.name,
}));
return (
<Section padding="xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Product Images */}
<div>
{product.featuredImage && (
<FeaturedImage
src={product.featuredImage}
alt={product.name}
size="full"
aspectRatio="4:3"
priority
/>
)}
{images.length > 1 && (
<div className="mt-4">
<ImageGallery images={images.slice(1)} cols={3} />
</div>
)}
</div>
{/* Product Info */}
<div className="space-y-6">
<div>
<h1 className="text-3xl md:text-4xl font-bold mb-2">{product.name}</h1>
<div className="flex items-center gap-3">
<span className="text-2xl font-bold text-primary">
{product.regularPrice} {product.currency}
</span>
{product.salePrice && (
<span className="text-lg text-gray-500 line-through">
{product.salePrice} {product.currency}
</span>
)}
</div>
</div>
<RichText html={product.descriptionHtml} />
{product.categories.length > 0 && (
<div>
<strong>Categories:</strong>
<div className="flex flex-wrap gap-2 mt-2">
{product.categories.map((cat) => (
<span key={cat.id} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{cat.name}
</span>
))}
</div>
</div>
)}
<div className="flex gap-3">
<Button variant="primary" size="lg">
Add to Cart
</Button>
<Button variant="outline" size="lg">
Contact Sales
</Button>
</div>
</div>
</div>
</Section>
);
};
// Example: Breadcrumbs with WordPress page structure
export const ExampleBreadcrumbs: React.FC<{
page: Page;
ancestors?: Page[];
}> = ({ page, ancestors = [] }) => {
const items = [
...ancestors.map((p) => ({
label: p.title,
href: `/${p.locale}${p.path}`,
})),
{
label: page.title,
},
];
return <Breadcrumbs items={items} />;
};
// Example: Full page layout with all components
export const ExamplePageLayout: React.FC<{
page: Page;
relatedPosts?: Post[];
}> = ({ page, relatedPosts = [] }) => {
const featuredMedia = page.featuredImage ? getMediaById(page.featuredImage) : null;
return (
<div className="min-h-screen">
{/* Hero Section */}
<Hero
title={page.title}
subtitle={page.excerptHtml ? page.excerptHtml.replace(/<[^>]*>/g, '') : undefined}
backgroundImage={featuredMedia?.localPath}
backgroundAlt={featuredMedia?.alt || page.title}
height="md"
variant="dark"
overlay
overlayOpacity={0.5}
/>
{/* Breadcrumbs */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: page.title },
]}
/>
{/* Main Content */}
<Section padding="xl">
<div className="max-w-4xl mx-auto">
<ContentRenderer content={page.contentHtml} />
</div>
</Section>
{/* Related Posts */}
{relatedPosts.length > 0 && (
<Section background="light" padding="xl">
<SectionHeader title="Related Content" align="center" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map((post) => (
<Card key={post.id} variant="bordered">
<CardBody>
<h3 className="text-lg font-bold mb-2">{post.title}</h3>
<RichText
html={post.excerptHtml}
className="text-gray-600 text-sm line-clamp-2"
/>
</CardBody>
<CardFooter>
<Button variant="ghost" size="sm">
Read More
</Button>
</CardFooter>
</Card>
))}
</div>
</Section>
)}
{/* CTA Section */}
<Section background="primary" padding="xl">
<div className="text-center text-white">
<h2 className="text-3xl font-bold mb-4">Ready to Get Started?</h2>
<p className="text-xl mb-6 opacity-90">
Contact us today for more information about our products and services.
</p>
<div className="flex justify-center gap-3">
<Button variant="secondary" size="lg">
Contact Us
</Button>
<Button variant="ghost" size="lg" className="text-white border-white hover:bg-white/10">
View Products
</Button>
</div>
</div>
</Section>
</div>
);
};
// Example: Blog post layout
export const ExampleBlogPost: React.FC<{
post: Post;
author?: {
name: string;
avatar?: string;
};
}> = ({ post, author }) => {
const featuredMedia = post.featuredImage ? getMediaById(post.featuredImage) : null;
return (
<article className="max-w-4xl mx-auto">
{/* Header */}
<header className="mb-8 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center justify-center gap-4 text-gray-600">
<time dateTime={post.datePublished}>
{new Date(post.datePublished).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
{author && (
<div className="flex items-center gap-2">
{author.avatar && <Avatar src={author.avatar} alt={author.name} size="sm" />}
<span>{author.name}</span>
</div>
)}
</div>
</header>
{/* Featured Image */}
{featuredMedia && (
<div className="mb-8">
<FeaturedImage
src={featuredMedia.localPath}
alt={featuredMedia.alt || post.title}
size="full"
aspectRatio="16:9"
priority
/>
</div>
)}
{/* Content */}
<div className="prose prose-lg max-w-none">
<ContentRenderer content={post.contentHtml} />
</div>
</article>
);
};
// Example: Product category grid
export const ExampleProductGrid: React.FC<{
products: Product[];
category?: string;
}> = ({ products, category }) => {
return (
<Section padding="xl">
<SectionHeader
title={category ? `${category} Products` : 'Our Products'}
subtitle="Explore our range of high-quality products"
align="center"
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => {
const image = product.featuredImage || product.images[0];
return (
<Card key={product.id} variant="elevated" className="flex flex-col">
{image && (
<div className="relative">
<FeaturedImage
src={image}
alt={product.name}
size="full"
aspectRatio="4:3"
/>
</div>
)}
<CardHeader>
<h3 className="text-lg font-bold">{product.name}</h3>
<div className="text-primary font-semibold text-lg">
{product.regularPrice} {product.currency}
</div>
</CardHeader>
<CardBody>
<RichText
html={product.shortDescriptionHtml}
className="text-gray-600 text-sm line-clamp-2"
/>
</CardBody>
<CardFooter className="mt-auto">
<Button variant="primary" size="sm" fullWidth>
View Details
</Button>
</CardFooter>
</Card>
);
})}
</div>
</Section>
);
};
export default {
ExampleHero,
ExampleSection,
ExampleContentGrid,
ExampleProductShowcase,
ExampleBreadcrumbs,
ExamplePageLayout,
ExampleBlogPost,
ExampleProductGrid,
};

View File

@@ -0,0 +1,667 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { cn } from '../../lib/utils';
import { processHTML } from '../../lib/html-compat';
import { getMediaByUrl, getMediaById, getAssetMap } from '../../lib/data';
interface ContentRendererProps {
content: string;
className?: string;
sanitize?: boolean;
processAssets?: boolean;
convertClasses?: boolean;
}
interface ProcessedImage {
src: string;
alt: string;
width?: number;
height?: number;
}
/**
* ContentRenderer Component
* Handles rendering of WordPress HTML content with proper sanitization
* and conversion to modern React components
*/
export const ContentRenderer: React.FC<ContentRendererProps> = ({
content,
className = '',
sanitize = true,
processAssets = true,
convertClasses = true,
}) => {
// Process the HTML content
const processedContent = React.useMemo(() => {
let html = content;
if (sanitize) {
html = processHTML(html);
}
if (processAssets) {
html = replaceWordPressAssets(html);
}
if (convertClasses) {
html = convertWordPressClasses(html);
}
return html;
}, [content, sanitize, processAssets, convertClasses]);
// Parse and render the HTML
const renderContent = () => {
if (!processedContent) return null;
// Use a parser to convert HTML to React elements
// For security, we'll use a custom parser that only allows safe elements
return parseHTMLToReact(processedContent);
};
return (
<div className={cn(
'prose prose-lg max-w-none',
'prose-headings:font-bold prose-headings:tracking-tight',
'prose-h1:text-3xl prose-h1:md:text-4xl prose-h1:mb-4',
'prose-h2:text-2xl prose-h2:md:text-3xl prose-h2:mb-3',
'prose-h3:text-xl prose-h3:md:text-2xl prose-h3:mb-2',
'prose-p:text-gray-700 prose-p:leading-relaxed prose-p:mb-4',
'prose-a:text-primary prose-a:hover:text-primary-dark prose-a:underline',
'prose-ul:list-disc prose-ul:pl-6 prose-ul:mb-4',
'prose-ol:list-decimal prose-ol:pl-6 prose-ol:mb-4',
'prose-li:mb-2 prose-li:marker:text-primary',
'prose-strong:font-bold prose-strong:text-gray-900',
'prose-em:italic prose-em:text-gray-700',
'prose-table:w-full prose-table:border-collapse prose-table:my-4',
'prose-th:bg-gray-100 prose-th:font-bold prose-th:p-2 prose-th:text-left',
'prose-td:p-2 prose-td:border prose-td:border-gray-200',
'prose-img:rounded-lg prose-img:shadow-md prose-img:my-4',
'prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic prose-blockquote:bg-gray-50 prose-blockquote:py-2 prose-blockquote:my-4',
className
)}>
{renderContent()}
</div>
);
};
/**
* Parse HTML string to React elements
* This is a safe parser that only allows specific tags and attributes
*/
function parseHTMLToReact(html: string): React.ReactNode {
// Define allowed tags and their properties
const allowedTags = {
div: ['className', 'id', 'style'],
p: ['className', 'style'],
h1: ['className', 'style'],
h2: ['className', 'style'],
h3: ['className', 'style'],
h4: ['className', 'style'],
h5: ['className', 'style'],
h6: ['className', 'style'],
span: ['className', 'style'],
a: ['href', 'target', 'rel', 'className', 'title', 'style'],
ul: ['className', 'style'],
ol: ['className', 'style'],
li: ['className', 'style'],
strong: ['className', 'style'],
b: ['className', 'style'],
em: ['className', 'style'],
i: ['className', 'style'],
br: [],
hr: ['className', 'style'],
img: ['src', 'alt', 'width', 'height', 'className', 'style'],
table: ['className', 'style'],
thead: ['className', 'style'],
tbody: ['className', 'style'],
tr: ['className', 'style'],
th: ['className', 'style'],
td: ['className', 'style'],
blockquote: ['className', 'style'],
code: ['className', 'style'],
pre: ['className', 'style'],
small: ['className', 'style'],
section: ['className', 'id', 'style'],
article: ['className', 'id', 'style'],
figure: ['className', 'style'],
figcaption: ['className', 'style'],
video: ['className', 'style', 'autoPlay', 'loop', 'muted', 'playsInline', 'poster'],
source: ['src', 'type'],
};
// Create a temporary DOM element to parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
// Recursive function to convert DOM nodes to React elements
function convertNode(node: Node, index: number): React.ReactNode {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const element = node as HTMLElement;
const tagName = element.tagName.toLowerCase();
// Check if tag is allowed
if (!allowedTags[tagName as keyof typeof allowedTags]) {
// For unknown tags, just render their children
return Array.from(node.childNodes).map((child, i) => convertNode(child, i));
}
// Build props
const props: any = { key: index };
const allowedProps = allowedTags[tagName as keyof typeof allowedTags];
// Helper function to convert style string to object
const parseStyleString = (styleStr: string): React.CSSProperties => {
const styles: React.CSSProperties = {};
if (!styleStr) return styles;
styleStr.split(';').forEach(style => {
const [key, value] = style.split(':').map(s => s.trim());
if (key && value) {
// Convert camelCase for React
const camelKey = key.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
(styles as any)[camelKey] = value;
}
});
return styles;
};
// Handle special cases for different element types
if (tagName === 'a' && element.getAttribute('href')) {
const href = element.getAttribute('href')!;
const isExternal = href.startsWith('http') && !href.includes(window?.location?.hostname || '');
if (isExternal) {
props.href = href;
props.target = '_blank';
props.rel = 'noopener noreferrer';
} else {
// For internal links, use Next.js Link
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
return (
<Link
href={href}
key={index}
className={element.className}
style={parseStyleString(element.style.cssText)}
>
{children}
</Link>
);
}
}
if (tagName === 'img') {
const src = element.getAttribute('src') || '';
const alt = element.getAttribute('alt') || '';
const widthAttr = element.getAttribute('width');
const heightAttr = element.getAttribute('height');
const dataWpImageId = element.getAttribute('data-wp-image-id');
// Handle WordPress image IDs
if (dataWpImageId) {
const media = getMediaById(parseInt(dataWpImageId));
if (media) {
const width = widthAttr ? parseInt(widthAttr) : (media.width || 800);
const height = heightAttr ? parseInt(heightAttr) : (media.height || 600);
return (
<Image
key={index}
src={media.localPath}
alt={alt || media.alt || ''}
width={width}
height={height}
className={element.className || ''}
style={parseStyleString(element.style.cssText)}
priority={false}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
);
}
}
// Handle regular image URLs
if (src) {
const imageProps = getImageProps(src);
const width = widthAttr ? parseInt(widthAttr) : imageProps.width;
const height = heightAttr ? parseInt(heightAttr) : imageProps.height;
// Check if it's an external URL
if (src.startsWith('http')) {
// For external images, use regular img tag
return (
<img
key={index}
src={imageProps.src}
alt={alt}
width={width}
height={height}
className={element.className || ''}
style={parseStyleString(element.style.cssText)}
/>
);
}
return (
<Image
key={index}
src={imageProps.src}
alt={alt || imageProps.alt || ''}
width={width || 800}
height={height || 600}
className={element.className || ''}
style={parseStyleString(element.style.cssText)}
priority={false}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
);
}
return null;
}
// Handle video elements
if (tagName === 'video') {
const videoProps: any = { key: index };
// Get sources
const sources: React.ReactNode[] = [];
Array.from(element.childNodes).forEach((child, i) => {
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === 'source') {
const sourceEl = child as HTMLSourceElement;
sources.push(
<source key={i} src={sourceEl.src} type={sourceEl.type} />
);
}
});
// Set video props
if (element.className) videoProps.className = element.className;
if (element.style.cssText) videoProps.style = parseStyleString(element.style.cssText);
if (element.getAttribute('autoPlay')) videoProps.autoPlay = true;
if (element.getAttribute('loop')) videoProps.loop = true;
if (element.getAttribute('muted')) videoProps.muted = true;
if (element.getAttribute('playsInline')) videoProps.playsInline = true;
if (element.getAttribute('poster')) videoProps.poster = element.getAttribute('poster');
return (
<video {...videoProps}>
{sources}
</video>
);
}
// Handle divs with special data attributes for backgrounds
if (tagName === 'div' && element.getAttribute('data-color-overlay')) {
const colorOverlay = element.getAttribute('data-color-overlay');
const overlayOpacity = parseFloat(element.getAttribute('data-overlay-opacity') || '0.5');
// Get the original classes and style
const className = element.className;
const style = parseStyleString(element.style.cssText);
// Convert children
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
return (
<div key={index} className={className} style={style}>
<div
className="absolute inset-0"
style={{ backgroundColor: colorOverlay, opacity: overlayOpacity }}
/>
<div className="relative">
{children}
</div>
</div>
);
}
// Handle divs with video background data attributes
if (tagName === 'div' && element.getAttribute('data-video-bg') === 'true') {
const className = element.className;
const style = parseStyleString(element.style.cssText);
const mp4 = element.getAttribute('data-video-mp4');
const webm = element.getAttribute('data-video-webm');
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
return (
<div key={index} className={className} style={style}>
{mp4 || webm ? (
<video
className="absolute inset-0 w-full h-full object-cover"
autoPlay
loop
muted
playsInline
>
{mp4 && <source src={mp4} type="video/mp4" />}
{webm && <source src={webm} type="video/webm" />}
</video>
) : null}
<div className="relative z-10">
{children}
</div>
</div>
);
}
// Standard attribute mapping
allowedProps.forEach(prop => {
if (prop === 'style') {
// Handle style separately
if (element.style.cssText) {
props.style = parseStyleString(element.style.cssText);
}
} else {
const value = element.getAttribute(prop);
if (value !== null) {
props[prop] = value;
}
}
});
// Handle className specifically
if (element.className && allowedProps.includes('className')) {
props.className = element.className;
}
// Convert children
const children = Array.from(node.childNodes).map((child, i) => convertNode(child, i));
// Return React element
return React.createElement(tagName, props, children);
}
// Convert all children of body
return Array.from(body.childNodes).map((node, index) => convertNode(node, index));
}
/**
* Replace WordPress asset URLs with local paths
*/
function replaceWordPressAssets(html: string): string {
try {
// Use the data layer to replace URLs
const assetMap = getAssetMap();
let processed = html;
// Replace URLs in src attributes
Object.entries(assetMap).forEach(([wpUrl, localPath]) => {
// Handle both full URLs and relative paths
const urlPattern = new RegExp(wpUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
processed = processed.replace(urlPattern, localPath as string);
});
// Also handle any remaining WordPress URLs that might be in the format we expect
processed = processed.replace(/https?:\/\/[^"'\s]+\/wp-content\/uploads\/\d{4}\/\d{2}\/([^"'\s]+)/g, (match, filename) => {
// Try to find this file in our media
const media = getMediaByUrl(match);
if (media) {
return media.localPath;
}
return match;
});
return processed;
} catch (error) {
console.warn('Error replacing asset URLs:', error);
return html;
}
}
/**
* Convert WordPress/Salient classes to Tailwind equivalents
*/
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',
// Typography
'wpb_wrapper': 'space-y-4',
'wpb_text_column': 'prose max-w-none',
'wpb_content_element': 'mb-8',
'wpb_single_image': 'my-4',
'wpb_heading': 'text-2xl font-bold mb-2',
// 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',
// Containers
'container': 'container mx-auto px-4',
'container-fluid': 'w-full px-4',
// 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',
// WordPress specific
'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
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;
}
/**
* Get image props from source using the data layer
*/
function getImageProps(src: string): { src: string; width?: number; height?: number; alt?: string } {
// Check if it's a data attribute for WordPress image ID
if (src.startsWith('data-wp-image-id:')) {
const imageId = src.replace('data-wp-image-id:', '');
const media = getMediaById(parseInt(imageId));
if (media) {
return {
src: media.localPath,
width: media.width || 800,
height: media.height || 600,
alt: media.alt || ''
};
}
}
// Try to find by URL
const media = getMediaByUrl(src);
if (media) {
return {
src: media.localPath,
width: media.width || 800,
height: media.height || 600,
alt: media.alt || ''
};
}
// Check if it's already a local path
if (src.startsWith('/media/')) {
return { src, width: 800, height: 600 };
}
// Return as-is for external URLs
return { src, width: 800, height: 600 };
}
/**
* Process background attributes and convert to inline styles
*/
function processBackgroundAttributes(element: HTMLElement): { style?: string; className?: string } {
const result: { style?: string; className?: string } = {};
const styles: string[] = [];
const classes: string[] = [];
// Check for data attributes from shortcodes
const bgImage = element.getAttribute('data-bg-image');
const bgVideo = element.getAttribute('data-video-bg');
const videoMp4 = element.getAttribute('data-video-mp4');
const videoWebm = element.getAttribute('data-video-webm');
const parallax = element.getAttribute('data-parallax');
// Handle background image
if (bgImage) {
const media = getMediaById(parseInt(bgImage));
if (media) {
styles.push(`background-image: url(${media.localPath})`);
styles.push('background-size: cover');
styles.push('background-position: center');
classes.push('bg-cover', 'bg-center');
}
}
// Handle video background
if (bgVideo === 'true' && (videoMp4 || videoWebm)) {
// This will be handled by a separate video component
// For now, we'll add a marker class
classes.push('has-video-background');
if (videoMp4) element.setAttribute('data-video-mp4', videoMp4);
if (videoWebm) element.setAttribute('data-video-webm', videoWebm);
}
// Handle parallax
if (parallax === 'true') {
classes.push('parallax-bg');
}
// Handle inline styles from shortcode attributes
const colorOverlay = element.getAttribute('color_overlay');
const overlayStrength = element.getAttribute('overlay_strength');
const topPadding = element.getAttribute('top_padding');
const bottomPadding = element.getAttribute('bottom_padding');
if (colorOverlay) {
const opacity = overlayStrength ? parseFloat(overlayStrength) : 0.5;
styles.push(`position: relative`);
classes.push('relative');
// Add overlay as a child element marker
element.setAttribute('data-color-overlay', colorOverlay);
element.setAttribute('data-overlay-opacity', opacity.toString());
}
if (topPadding) {
styles.push(`padding-top: ${topPadding}`);
}
if (bottomPadding) {
styles.push(`padding-bottom: ${bottomPadding}`);
}
if (styles.length > 0) {
result.style = styles.join('; ');
}
if (classes.length > 0) {
result.className = classes.join(' ');
}
return result;
}
// Sub-components for specific content types
export const ContentBlock: React.FC<{
title?: string;
content: string;
className?: string;
}> = ({ title, content, className = '' }) => (
<div className={cn('mb-8', className)}>
{title && <h3 className="text-2xl font-bold mb-4">{title}</h3>}
<ContentRenderer content={content} />
</div>
);
export const RichText: React.FC<{
html: string;
className?: string;
}> = ({ html, className = '' }) => (
<ContentRenderer content={html} className={className} />
);
export default ContentRenderer;

View File

@@ -0,0 +1,252 @@
import React from 'react';
import Image from 'next/image';
import { cn } from '../../lib/utils';
import { getViewport, generateImageSizes, getOptimalImageQuality } from '../../lib/responsive';
// Aspect ratio options
type AspectRatio = '1:1' | '4:3' | '16:9' | '21:9' | 'auto';
// Size options
type ImageSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
interface FeaturedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
aspectRatio?: AspectRatio;
size?: ImageSize;
caption?: string;
priority?: boolean;
className?: string;
objectFit?: 'cover' | 'contain' | 'fill';
lazy?: boolean;
// Responsive props
responsiveSrc?: {
mobile?: string;
tablet?: string;
desktop?: string;
};
// Quality optimization
quality?: number | 'auto';
// Placeholder
placeholder?: 'blur' | 'empty';
blurDataURL?: string;
}
// Helper function to get aspect ratio classes
const getAspectRatio = (ratio: AspectRatio) => {
switch (ratio) {
case '1:1':
return 'aspect-square';
case '4:3':
return 'aspect-[4/3]';
case '16:9':
return 'aspect-video';
case '21:9':
return 'aspect-[21/9]';
case 'auto':
return 'aspect-auto';
default:
return 'aspect-auto';
}
};
// Helper function to get size classes
const getSizeStyles = (size: ImageSize) => {
switch (size) {
case 'sm':
return 'max-w-xs';
case 'md':
return 'max-w-md';
case 'lg':
return 'max-w-lg';
case 'xl':
return 'max-w-xl';
case 'full':
return 'max-w-full';
default:
return 'max-w-lg';
}
};
export const FeaturedImage: React.FC<FeaturedImageProps> = ({
src,
alt,
width,
height,
aspectRatio = 'auto',
size = 'md',
caption,
priority = false,
className = '',
objectFit = 'cover',
lazy = true,
responsiveSrc,
quality = 'auto',
placeholder = 'empty',
blurDataURL,
}) => {
const hasDimensions = width && height;
const shouldLazyLoad = !priority && lazy;
// Get responsive image source
const getResponsiveSrc = () => {
if (responsiveSrc) {
if (typeof window === 'undefined') return responsiveSrc.mobile || src;
const viewport = getViewport();
if (viewport.isMobile && responsiveSrc.mobile) return responsiveSrc.mobile;
if (viewport.isTablet && responsiveSrc.tablet) return responsiveSrc.tablet;
if (viewport.isDesktop && responsiveSrc.desktop) return responsiveSrc.desktop;
}
return src;
};
// Get optimal quality
const getQuality = () => {
if (quality === 'auto') {
if (typeof window === 'undefined') return 75;
const viewport = getViewport();
return getOptimalImageQuality(viewport);
}
return quality;
};
// Generate responsive sizes attribute
const getSizes = () => {
const baseSizes = generateImageSizes();
// Adjust based on component size prop
switch (size) {
case 'sm':
return '(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw';
case 'md':
return '(max-width: 640px) 75vw, (max-width: 768px) 50vw, 33vw';
case 'lg':
return baseSizes;
case 'xl':
return '(max-width: 640px) 100vw, (max-width: 768px) 75vw, 50vw';
case 'full':
return '100vw';
default:
return baseSizes;
}
};
const responsiveImageSrc = getResponsiveSrc();
const optimalQuality = getQuality();
return (
<figure className={cn('relative', getSizeStyles(size), className)}>
<div className={cn(
'relative overflow-hidden rounded-lg',
getAspectRatio(aspectRatio),
// Ensure container has dimensions if aspect ratio is specified
aspectRatio !== 'auto' && 'w-full',
// Mobile-optimized border radius
'sm:rounded-lg'
)}>
<Image
src={responsiveImageSrc}
alt={alt}
width={hasDimensions ? width : undefined}
height={hasDimensions ? height : undefined}
fill={!hasDimensions}
priority={priority}
loading={shouldLazyLoad ? 'lazy' : 'eager'}
quality={optimalQuality}
placeholder={placeholder}
blurDataURL={blurDataURL}
className={cn(
'transition-transform duration-300 ease-in-out',
objectFit === 'cover' && 'object-cover',
objectFit === 'contain' && 'object-contain',
objectFit === 'fill' && 'object-fill',
// Smooth scaling on mobile, more pronounced on desktop
'active:scale-95 md:hover:scale-105',
// Ensure no layout shift
'bg-gray-100'
)}
sizes={getSizes()}
// Add loading optimization
fetchPriority={priority ? 'high' : 'low'}
/>
</div>
{caption && (
<figcaption className={cn(
'mt-2 text-sm text-gray-600',
'text-center italic',
// Mobile-optimized text size
'text-xs sm:text-sm'
)}>
{caption}
</figcaption>
)}
</figure>
);
};
// Sub-components for common image patterns
export const Avatar: React.FC<{
src: string;
alt: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}> = ({ src, alt, size = 'md', className = '' }) => {
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
}[size];
return (
<div className={cn(
'relative overflow-hidden rounded-full',
sizeClasses,
className
)}>
<Image
src={src}
alt={alt}
fill
className="object-cover"
sizes={`${sizeClasses}`}
/>
</div>
);
};
export const ImageGallery: React.FC<{
images: Array<{
src: string;
alt: string;
caption?: string;
}>;
cols?: 2 | 3 | 4;
className?: string;
}> = ({ images, cols = 3, className = '' }) => {
const colClasses = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
}[cols];
return (
<div className={cn('grid gap-4', colClasses, className)}>
{images.map((image, index) => (
<FeaturedImage
key={index}
src={image.src}
alt={image.alt}
caption={image.caption}
size="full"
aspectRatio="4:3"
/>
))}
</div>
);
};
export default FeaturedImage;

223
components/content/Hero.tsx Normal file
View File

@@ -0,0 +1,223 @@
import React 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';
// Hero variant options
type HeroVariant = 'default' | 'dark' | 'primary' | 'gradient';
interface HeroProps {
title: string;
subtitle?: string;
backgroundImage?: string;
backgroundAlt?: string;
height?: HeroHeight;
variant?: HeroVariant;
ctaText?: string;
ctaLink?: string;
ctaVariant?: 'primary' | 'secondary' | 'outline';
overlay?: boolean;
overlayOpacity?: number;
children?: React.ReactNode;
className?: 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]';
}
};
// Helper function to get variant styles
const getVariantStyles = (variant: HeroVariant) => {
switch (variant) {
case 'dark':
return 'bg-gray-900 text-white';
case 'primary':
return 'bg-primary text-white';
case 'gradient':
return 'bg-gradient-to-br from-primary to-secondary text-white';
default:
return 'bg-gray-800 text-white';
}
};
// Helper function to get overlay opacity
const getOverlayOpacity = (opacity?: number) => {
if (opacity === undefined) return 'bg-black/50';
if (opacity >= 1) return 'bg-black';
if (opacity <= 0) return 'bg-transparent';
return `bg-black/${Math.round(opacity * 100)}`;
};
export const Hero: React.FC<HeroProps> = ({
title,
subtitle,
backgroundImage,
backgroundAlt = '',
height = 'md',
variant = 'default',
ctaText,
ctaLink,
ctaVariant = 'primary',
overlay = true,
overlayOpacity,
children,
className = '',
}) => {
const hasBackground = !!backgroundImage;
const hasCTA = !!ctaText && !!ctaLink;
return (
<section
className={cn(
'relative w-full overflow-hidden flex items-center justify-center',
getHeightStyles(height),
className
)}
>
{/* Background Image */}
{hasBackground && (
<div className="absolute inset-0 z-0">
<Image
src={backgroundImage}
alt={backgroundAlt || title}
fill
priority
className="object-cover"
sizes="100vw"
/>
</div>
)}
{/* Background Variant (if no image) */}
{!hasBackground && (
<div className={cn(
'absolute inset-0 z-0',
getVariantStyles(variant)
)} />
)}
{/* Overlay */}
{overlay && hasBackground && (
<div className={cn(
'absolute inset-0 z-10',
getOverlayOpacity(overlayOpacity)
)} />
)}
{/* Content */}
<div className="relative z-20 w-full">
<Container
maxWidth="6xl"
padding="lg"
className={cn(
'text-center',
// Add padding for full-height heroes
height === 'full' && 'py-12 md:py-20'
)}
>
{/* Title */}
<h1
className={cn(
'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'
)}
>
{title}
</h1>
{/* Subtitle */}
{subtitle && (
<p
className={cn(
'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'
)}
>
{subtitle}
</p>
)}
{/* CTA Button */}
{hasCTA && (
<div className="flex justify-center">
<Button
variant={ctaVariant}
size="lg"
onClick={() => {
if (ctaLink) {
// Handle both internal and external links
if (ctaLink.startsWith('http')) {
window.open(ctaLink, '_blank');
} else {
// For Next.js routing, you'd use the router
// This is a fallback for external links
window.location.href = ctaLink;
}
}
}}
className="animate-fade-in-up"
>
{ctaText}
</Button>
</div>
)}
{/* Additional Content */}
{children && (
<div className="mt-8">
{children}
</div>
)}
</Container>
</div>
</section>
);
};
// Sub-components for more complex hero layouts
export const HeroContent: React.FC<{
title: string;
subtitle?: string;
children?: React.ReactNode;
className?: string;
}> = ({ title, subtitle, children, className = '' }) => (
<div className={cn('space-y-4 md:space-y-6', className)}>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold">{title}</h2>
{subtitle && <p className="text-lg md:text-xl text-gray-200">{subtitle}</p>}
{children}
</div>
);
export const HeroActions: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className = '' }) => (
<div className={cn('flex flex-wrap gap-3 justify-center', className)}>
{children}
</div>
);
export default Hero;

View File

@@ -0,0 +1,350 @@
# Content Components
Modern, component-based content rendering system for WordPress migration to Next.js. These components handle WordPress content display in a responsive, accessible, and SEO-friendly way.
## Components Overview
### 1. Hero Component (`Hero.tsx`)
Full-width hero section with background image support, overlays, and CTAs.
**Features:**
- Background images with Next.js Image optimization
- Text overlay for readability
- Multiple height options (sm, md, lg, xl, full)
- Optional CTA button
- Responsive sizing
- Variant support (default, dark, primary, gradient)
**Usage:**
```tsx
import { Hero } from '@/components/content';
<Hero
title="Welcome to KLZ Cables"
subtitle="High-quality cable solutions for industrial applications"
backgroundImage="/media/hero-image.jpg"
height="lg"
variant="dark"
overlay
overlayOpacity={0.6}
ctaText="Learn More"
ctaLink="/products"
/>
```
### 2. ContentRenderer Component (`ContentRenderer.tsx`)
Sanitizes and renders HTML content from WordPress with class conversion.
**Features:**
- HTML sanitization (removes scripts, styles, dangerous attributes)
- WordPress class to Tailwind conversion
- Next.js Image component integration
- Shortcode processing
- Safe HTML parsing
- SEO-friendly markup
**Usage:**
```tsx
import { ContentRenderer } from '@/components/content';
<ContentRenderer
content={page.contentHtml}
sanitize={true}
processAssets={true}
convertClasses={true}
/>
```
**Supported HTML Elements:**
- Typography: `<p>`, `<h1-h6>`, `<strong>`, `<em>`, `<small>`
- Lists: `<ul>`, `<ol>`, `<li>`
- Links: `<a>` (with Next.js Link optimization)
- Images: `<img>` (with Next.js Image)
- Tables: `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`
- Layout: `<div>`, `<span>`, `<section>`, `<article>`, `<figure>`, `<figcaption>`
- Code: `<code>`, `<pre>`
- Quotes: `<blockquote>`
### 3. FeaturedImage Component (`FeaturedImage.tsx`)
Optimized image display with responsive sizing and lazy loading.
**Features:**
- Next.js Image optimization
- Lazy loading
- Aspect ratio control
- Caption support
- Alt text handling
- Priority loading option
**Usage:**
```tsx
import { FeaturedImage } from '@/components/content';
<FeaturedImage
src="/media/product.jpg"
alt="Product image"
aspectRatio="16:9"
size="lg"
caption="Product overview"
priority={false}
/>
```
**Aspect Ratios:**
- `1:1` - Square
- `4:3` - Standard
- `16:9` - Widescreen
- `21:9` - Ultrawide
- `auto` - Natural
**Sizes:**
- `sm` - max-w-xs
- `md` - max-w-md
- `lg` - max-w-lg
- `xl` - max-w-xl
- `full` - max-w-full
### 4. Section Component (`Section.tsx`)
Wrapper for content sections with background and padding options.
**Features:**
- Background color variants
- Padding options
- Container integration
- Full-width support
- Semantic HTML
**Usage:**
```tsx
import { Section, SectionHeader, SectionContent } from '@/components/content';
<Section background="light" padding="xl">
<SectionHeader
title="Our Products"
subtitle="Browse our extensive catalog"
align="center"
/>
<SectionContent>
{/* Your content here */}
</SectionContent>
</Section>
```
**Background Options:**
- `default` - White
- `light` - Light gray
- `dark` - Dark gray with white text
- `primary` - Primary color with white text
- `secondary` - Secondary color with white text
- `gradient` - Gradient from primary to secondary
**Padding Options:**
- `none` - No padding
- `sm` - Small
- `md` - Medium
- `lg` - Large
- `xl` - Extra large
- `2xl` - Double extra large
### 5. Breadcrumbs Component (`Breadcrumbs.tsx`)
SEO-friendly breadcrumb navigation with schema.org markup.
**Features:**
- Schema.org structured data
- Home icon
- Responsive (collapses on mobile)
- Customizable separators
- Multiple variants
**Usage:**
```tsx
import { Breadcrumbs } from '@/components/content';
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Cables', href: '/products/cables' },
{ label: 'Current Page' }
]}
separator="/"
showHome={true}
collapseMobile={true}
/>
```
**Variants:**
- `BreadcrumbsCompact` - Minimal design
- `BreadcrumbsSimple` - Basic navigation
## Integration with WordPress Data
All components are designed to work seamlessly with the WordPress data layer:
```tsx
import {
Hero,
Section,
ContentRenderer,
FeaturedImage,
Breadcrumbs
} from '@/components/content';
import { getPageBySlug, getMediaById } from '@/lib/data';
export default async function Page({ params }) {
const page = getPageBySlug(params.slug, params.locale);
const featuredMedia = page.featuredImage ? getMediaById(page.featuredImage) : null;
return (
<>
<Breadcrumbs
items={[
{ label: 'Home', href: `/${params.locale}` },
{ label: page.title }
]}
/>
<Hero
title={page.title}
backgroundImage={featuredMedia?.localPath}
height="md"
/>
<Section padding="xl">
<ContentRenderer content={page.contentHtml} />
</Section>
</>
);
}
```
## WordPress Class Conversion
The ContentRenderer automatically converts common WordPress/Salient classes to Tailwind:
| WordPress Class | Tailwind Equivalent |
|----------------|---------------------|
| `vc_row` | `flex flex-wrap -mx-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` |
| `text-center` | `text-center` |
| `bg-light` | `bg-gray-50` |
| `btn btn-primary` | `inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold bg-primary text-white hover:bg-primary-dark` |
| `wpb_wrapper` | `space-y-4` |
| `accent-color` | `text-primary` |
## Security Features
All components include security measures:
1. **HTML Sanitization**: Removes scripts, styles, and dangerous attributes
2. **URL Validation**: Validates and sanitizes all URLs
3. **XSS Prevention**: Removes inline event handlers and javascript: URLs
4. **Safe Parsing**: Only allows safe HTML elements and attributes
5. **Asset Validation**: Validates image sources and dimensions
## Performance Optimization
- **Lazy Loading**: Images load only when needed
- **Next.js Image**: Automatic optimization and WebP support
- **Priority Loading**: Critical images can be preloaded
- **Efficient Rendering**: Memoized processing for large content
- **Responsive Images**: Proper `sizes` attribute for different viewports
## Internationalization
All components support internationalization:
```tsx
<Hero
title={t('hero.title')}
subtitle={t('hero.subtitle')}
ctaText={t('hero.cta')}
/>
```
## Accessibility
- Semantic HTML elements
- Proper heading hierarchy
- Alt text for images
- ARIA labels where needed
- Keyboard navigation support
- Screen reader friendly
## Customization
All components accept `className` props for custom styling:
```tsx
<Hero
title="Custom Hero"
className="custom-hero-class"
// ... other props
/>
```
## TypeScript Support
All components include full TypeScript definitions:
```tsx
import { HeroProps, SectionProps } from '@/components/content';
const heroConfig: HeroProps = {
title: 'My Hero',
height: 'lg',
// TypeScript will guide you through all options
};
```
## Best Practices
1. **Always provide alt text** for images
2. **Use priority** for above-the-fold images
3. **Sanitize content** from untrusted sources
4. **Process assets** to ensure local file availability
5. **Convert classes** for consistent styling
6. **Add breadcrumbs** for SEO and navigation
7. **Use semantic sections** for content structure
## Migration Notes
When migrating from WordPress:
1. Export content with `contentHtml` fields
2. Download and host media files locally
3. Map WordPress URLs to local paths
4. Test class conversion for custom themes
5. Verify shortcodes are processed or removed
6. Check responsive behavior on all devices
7. Validate SEO markup (schema.org)
## Component Composition
Components can be composed together:
```tsx
<Section background="dark" padding="xl">
<div className="text-center text-white">
<h2 className="text-3xl font-bold mb-4">Featured Products</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
{products.map(product => (
<Card key={product.id}>
<FeaturedImage
src={product.image}
alt={product.name}
aspectRatio="4:3"
/>
<CardBody>
<h3>{product.name}</h3>
<ContentRenderer content={product.description} />
</CardBody>
</Card>
))}
</div>
</div>
</Section>
```
This creates a cohesive, modern content system that maintains WordPress flexibility while leveraging Next.js capabilities.

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { Container } from '../ui/Container';
// Section background options
type SectionBackground = 'default' | 'light' | 'dark' | 'primary' | 'secondary' | 'gradient';
// Section padding options
type SectionPadding = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
interface SectionProps {
children: React.ReactNode;
background?: SectionBackground;
padding?: SectionPadding;
fullWidth?: boolean;
className?: string;
id?: string;
as?: React.ElementType;
}
// Helper function to get background styles
const getBackgroundStyles = (background: SectionBackground) => {
switch (background) {
case 'light':
return 'bg-gray-50';
case 'dark':
return 'bg-gray-900 text-white';
case 'primary':
return 'bg-primary text-white';
case 'secondary':
return 'bg-secondary text-white';
case 'gradient':
return 'bg-gradient-to-br from-primary to-secondary text-white';
default:
return 'bg-white';
}
};
// Helper function to get padding styles
const getPaddingStyles = (padding: SectionPadding) => {
switch (padding) {
case 'none':
return 'py-0';
case 'sm':
return 'py-4 sm:py-6';
case 'md':
return 'py-8 sm:py-12';
case 'lg':
return 'py-12 sm:py-16';
case 'xl':
return 'py-16 sm:py-20 md:py-24';
case '2xl':
return 'py-20 sm:py-24 md:py-32';
default:
return 'py-12 sm:py-16';
}
};
export const Section: React.FC<SectionProps> = ({
children,
background = 'default',
padding = 'md',
fullWidth = false,
className = '',
id,
as: Component = 'section',
}) => {
const sectionClasses = cn(
'w-full',
getBackgroundStyles(background),
getPaddingStyles(padding),
className
);
const content = fullWidth ? (
<div className={sectionClasses} id={id}>
{children}
</div>
) : (
<section className={sectionClasses} id={id}>
<Container maxWidth="6xl" padding="md">
{children}
</Container>
</section>
);
if (Component !== 'section' && !fullWidth) {
return (
<Component className={sectionClasses} id={id}>
<Container maxWidth="6xl" padding="md">
{children}
</Container>
</Component>
);
}
return content;
};
// Sub-components for common section patterns
export const SectionHeader: React.FC<{
title: string;
subtitle?: string;
align?: 'left' | 'center' | 'right';
className?: string;
}> = ({ title, subtitle, align = 'center', className = '' }) => {
const alignment = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[align];
return (
<div className={cn('mb-8 md:mb-12', alignment, className)}>
<h2 className={cn(
'text-3xl md:text-4xl font-bold mb-3',
'leading-tight tracking-tight'
)}>
{title}
</h2>
{subtitle && (
<p className={cn(
'text-lg md:text-xl',
'max-w-3xl mx-auto',
'opacity-90'
)}>
{subtitle}
</p>
)}
</div>
);
};
export const SectionContent: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className = '' }) => (
<div className={cn('space-y-6 md:space-y-8', className)}>
{children}
</div>
);
export const SectionGrid: React.FC<{
children: React.ReactNode;
cols?: 1 | 2 | 3 | 4;
gap?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}> = ({ children, cols = 3, gap = 'md', className = '' }) => {
const gapClasses = {
sm: 'gap-4 md:gap-6',
md: 'gap-6 md:gap-8',
lg: 'gap-8 md:gap-12',
xl: 'gap-10 md:gap-16',
}[gap];
const colClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
}[cols];
return (
<div className={cn('grid', colClasses, gapClasses, className)}>
{children}
</div>
);
};
export default Section;

View File

@@ -0,0 +1,6 @@
// Content Components Export
export { Hero, HeroContent, HeroActions } from './Hero';
export { Section, SectionHeader, SectionContent, SectionGrid } from './Section';
export { FeaturedImage, Avatar, ImageGallery } from './FeaturedImage';
export { Breadcrumbs, BreadcrumbsCompact, BreadcrumbsSimple } from './Breadcrumbs';
export { ContentRenderer, ContentBlock, RichText } from './ContentRenderer';