migration wip

This commit is contained in:
2025-12-30 12:10:13 +01:00
parent 89dbf8af87
commit 65a7e9f24a
203 changed files with 192475 additions and 1562 deletions

View File

@@ -0,0 +1,224 @@
'use client';
import React from 'react';
import { cn } from '../../lib/utils';
import { Star, Quote } from 'lucide-react';
export interface TestimonialCardProps {
quote: string;
author?: string;
role?: string;
company?: string;
rating?: number;
avatar?: string;
variant?: 'default' | 'highlight' | 'compact';
className?: string;
}
/**
* TestimonialCard Component
* Displays customer testimonials with optional ratings and author info
* Maps to WordPress testimonial patterns and quote blocks
*/
export const TestimonialCard: React.FC<TestimonialCardProps> = ({
quote,
author,
role,
company,
rating = 0,
avatar,
variant = 'default',
className = ''
}) => {
// Generate star rating
const renderStars = () => {
if (!rating || rating === 0) return null;
const stars = [];
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
for (let i = 0; i < fullStars; i++) {
stars.push(
<Star key={`full-${i}`} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
);
}
if (hasHalfStar) {
stars.push(
<Star key="half" className="w-4 h-4 fill-yellow-400 text-yellow-400 opacity-50" />
);
}
const emptyStars = 5 - stars.length;
for (let i = 0; i < emptyStars; i++) {
stars.push(
<Star key={`empty-${i}`} className="w-4 h-4 text-gray-300" />
);
}
return <div className="flex gap-1">{stars}</div>;
};
// Variant-specific styles
const variantStyles = {
default: 'bg-white border border-gray-200 shadow-sm',
highlight: 'bg-gradient-to-br from-primary/5 to-secondary/5 border-primary/20 shadow-lg',
compact: 'bg-gray-50 border border-gray-100 shadow-sm'
};
const paddingStyles = {
default: 'p-6 md:p-8',
highlight: 'p-6 md:p-8',
compact: 'p-4 md:p-6'
};
const quoteIconStyles = {
default: 'w-8 h-8 text-primary/30',
highlight: 'w-10 h-10 text-primary/50',
compact: 'w-6 h-6 text-primary/30'
};
return (
<div className={cn(
'relative rounded-xl',
variantStyles[variant],
paddingStyles[variant],
'transition-all duration-200',
'hover:shadow-md hover:-translate-y-1',
className
)}>
{/* Quote Icon */}
<div className={cn(
'absolute top-4 left-4 md:top-6 md:left-6',
'opacity-90',
quoteIconStyles[variant]
)}>
<Quote />
</div>
{/* Main Content */}
<div className={cn(
'space-y-4 md:space-y-6',
'pl-6 md:pl-8' // Space for quote icon
)}>
{/* Quote Text */}
<blockquote className={cn(
'text-gray-700 leading-relaxed',
variant === 'highlight' && 'text-gray-800 font-medium',
variant === 'compact' && 'text-sm md:text-base'
)}>
"{quote}"
</blockquote>
{/* Rating */}
{rating > 0 && (
<div className="flex items-center gap-2">
{renderStars()}
<span className={cn(
'text-sm font-medium',
variant === 'highlight' ? 'text-primary' : 'text-gray-600'
)}>
{rating.toFixed(1)}
</span>
</div>
)}
{/* Author Info */}
{(author || role || company || avatar) && (
<div className={cn(
'flex items-start gap-3 md:gap-4',
variant === 'compact' && 'gap-2'
)}>
{/* Avatar */}
{avatar && (
<div className={cn(
'flex-shrink-0 rounded-full overflow-hidden',
'w-10 h-10 md:w-12 md:h-12',
variant === 'compact' && 'w-8 h-8'
)}>
<img
src={avatar}
alt={author || 'Avatar'}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Author Details */}
<div className="flex-1 min-w-0">
{author && (
<div className={cn(
'font-semibold text-gray-900',
variant === 'highlight' && 'text-lg',
variant === 'compact' && 'text-base'
)}>
{author}
</div>
)}
{(role || company) && (
<div className={cn(
'text-sm',
'text-gray-600',
variant === 'compact' && 'text-xs'
)}>
{[role, company].filter(Boolean).join(' • ')}
</div>
)}
</div>
</div>
)}
</div>
{/* Decorative corner accent for highlight variant */}
{variant === 'highlight' && (
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-bl from-primary/20 to-transparent rounded-tr-xl" />
)}
</div>
);
};
// Helper function to parse WordPress testimonial content
export function parseWpTestimonial(content: string): Partial<TestimonialCardProps> {
// This would parse WordPress testimonial patterns
// For now, returns basic structure
return {
quote: content.replace(/<[^>]*>/g, '').trim().substring(0, 300) // Strip HTML, limit length
};
}
// Grid wrapper for multiple testimonials
export const TestimonialGrid: React.FC<{
testimonials: TestimonialCardProps[];
columns?: 1 | 2 | 3;
gap?: 'sm' | 'md' | 'lg';
className?: string;
}> = ({ testimonials, columns = 2, gap = 'md', className = '' }) => {
const gapStyles = {
sm: 'gap-4',
md: 'gap-6',
lg: 'gap-8'
};
const columnStyles = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
};
return (
<div className={cn(
'grid',
columnStyles[columns],
gapStyles[gap],
className
)}>
{testimonials.map((testimonial, index) => (
<TestimonialCard key={index} {...testimonial} />
))}
</div>
);
};
export default TestimonialCard;

View File

@@ -34,13 +34,14 @@ export {
} from './CategoryCard';
// Card Grid Components
export {
CardGrid,
CardGrid2,
CardGrid3,
CardGrid4,
export {
CardGrid,
CardGrid2,
CardGrid3,
CardGrid4,
CardGridAuto,
type CardGridProps,
type GridColumns,
type GridGap
} from './CardGrid';
type GridGap
} from './CardGrid';
export { TestimonialCard, TestimonialGrid, parseWpTestimonial, type TestimonialCardProps } from './TestimonialCard';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
# WPBakery to Next.js Component Mapping - Detailed Analysis
This document provides detailed per-page analysis of WPBakery patterns found in the WordPress site and how they are mapped to modern React components.
## Pages Analyzed
### 1. Home Pages (corporate-3-landing-2 / start)
**Patterns Found:**
- 25 vc-rows with 43 vc-columns total
- Complex nested structure (24 nested rows)
- Hero section with h1
- Numbered features (h6 + h4 + p)
- Card grids (2-4 columns)
- Testimonial sections (16 instances)
- Team member sections
**Specific Mappings:**
```html
<!-- Hero Pattern -->
<div class="vc-row">
<div class="vc-column">
<h1>We are helping to expand...</h1>
<p>Subtitle</p>
</div>
</div>
<!-- Maps to: <Hero title="..." subtitle="..." /> -->
<!-- Numbered Features (Home Style) -->
<div class="vc-row">
<div class="vc-column">
<h6>01</h6>
<h4>Supply to energy suppliers...</h4>
<p>Description...</p>
</div>
</div>
<!-- Maps to: Flex layout with number + title + description -->
<!-- Nested Rows -->
<div class="vc-row">
<div class="vc-column">
<div class="vc-row">...</div>
</div>
</div>
<!-- Maps to: Recursive ContentRenderer with parsePatterns=true -->
```
**Home-Specific Enhancements:**
- Handles 25+ row complexity through recursive parsing
- Preserves nested structure for proper layout
- Converts numbered features to modern flex layout
- Maps testimonial quotes to styled blocks
### 2. Team Pages (team)
**Patterns Found:**
- 8 vc-rows with 16 vc-columns
- 4 testimonial sections with quotes
- 7 nested rows
- Quote patterns with German quotes („“)
**Specific Mappings:**
```html
<!-- Testimonial/Quote Pattern -->
<div class="vc-row">
<div class="vc-column">
<h1>Michael Bodemer</h1>
<h2>„Challenges exist to be solved...“</h2>
<p>Detailed description...</p>
</div>
</div>
<!-- Maps to:
<Section>
<div className="bg-gray-50 p-6 rounded-lg border-l-4 border-primary">
<h3>Michael Bodemer</h3>
<blockquote>„Challenges exist...“</blockquote>
</div>
</Section>
-->
```
### 3. Terms Pages (terms / agbs)
**Patterns Found:**
- 19 vc-rows with 38 vc-columns
- Numbered features with h6 + h3 (different from home)
- PDF download link
- 2 testimonial sections
**Specific Mappings:**
```html
<!-- Numbered Terms Pattern -->
<div class="vc-row">
<div class="vc-column">
<h6>1.</h6>
<h3>Allgemeines</h3>
<p>Paragraph 1...</p>
<p>Paragraph 2...</p>
</div>
</div>
<!-- Maps to:
<div className="mb-6">
<div className="flex items-start gap-4">
<div className="text-3xl font-bold text-primary">1.</div>
<div className="flex-1">
<h3 className="text-2xl font-bold mb-2">Allgemeines</h3>
</div>
</div>
<div className="ml-11 mt-2">Paragraph 1...</div>
<div className="ml-11 mt-2">Paragraph 2...</div>
</div>
-->
<!-- PDF Download -->
<div class="vc-row">
<div class="vc-column">
<a href=".../agbs.pdf">Download als PDF</a>
</div>
</div>
<!-- Maps to:
<Section>
<div className="bg-blue-50 p-4 rounded-lg">
<a href="/media/agbs.pdf" className="text-primary hover:underline">
📄 Download als PDF
</a>
</div>
</Section>
-->
```
### 4. Contact Pages (contact / kontakt)
**Patterns Found:**
- 4 vc-rows with 7 vc-columns
- Contact form (frm_forms)
- Contact info blocks
- 3 nested rows
**Specific Mappings:**
```html
<!-- Contact Form Pattern -->
<div class="vc-row">
<div class="vc-column">
<div class="frm_forms">...</form>
</div>
</div>
<!-- Maps to:
<Section>
<ContactForm />
</Section>
-->
<!-- Contact Info Pattern -->
<div class="vc-row">
<div class="vc-column">
<p>KLZ Cables<br/>Raiffeisenstraße 22<br/>73630 Remshalden</p>
</div>
</div>
<!-- Maps to:
<Section>
<div className="bg-gray-100 p-6 rounded-lg">
<ContentRenderer content="..." parsePatterns={false} />
</div>
</Section>
-->
```
### 5. Legal/Privacy Pages (legal-notice, privacy-policy, impressum, datenschutz)
**Patterns Found:**
- 1 vc-row with 2 vc-columns
- Hero section with h1
- Simple content structure
- Contact info blocks
**Specific Mappings:**
```html
<!-- Simple Hero + Content -->
<div class="vc-row">
<div class="vc-column">
<h1>Legal Notice</h1>
<p>Content...</p>
</div>
</div>
<!-- Maps to:
<Hero title="Legal Notice" />
<Section>
<ContentRenderer content="..." parsePatterns={false} />
</Section>
-->
```
### 6. Thanks Pages (thanks / danke)
**Patterns Found:**
- 2 vc-rows with 3 vc-columns
- Hero pattern
- Grid structure
**Specific Mappings:**
```html
<!-- Thank You Hero -->
<div class="vc-row">
<div class="vc-column">
<h2>Thank you very much!</h2>
<p>We've received your message...</p>
</div>
</div>
<!-- Maps to:
<Hero title="Thank you very much!" subtitle="We've received your message..." />
-->
```
### 7. Blog Pages (blog)
**Patterns Found:**
- 2 vc-rows (empty or minimal content)
- No specific patterns
**Specific Mappings:**
- Falls back to generic parsing
- Uses page-specific routing for blog listing
### 8. Products Pages (products / produkte)
**Patterns Found:**
- Empty content
- Uses page-specific routing for product catalog
**Specific Mappings:**
- Routes to `/products` or `/produkte` page components
- Uses ProductList component
## Parser Pattern Priority
The parser processes patterns in this order:
1. **Hero Sections** - Single column with h1/h2
2. **Contact Forms** - Forms with frm_forms class
3. **Numbered Features (Home)** - h6 + h4 structure
4. **Numbered Features (Terms)** - h6 + h3 structure
5. **Testimonials/Quotes** - Contains quotes or team structure
6. **PDF Downloads** - Links ending in .pdf
7. **Contact Info** - Contains @, addresses, or KLZ Cables
8. **Grid/Card Patterns** - 2-4 columns with titles/images
9. **Nested Rows** - Rows containing other rows
10. **Simple Content** - h3 + p structure
11. **Empty Rows** - Whitespace only
12. **Fallback** - Generic section
## Component Props Enhancement
### ContentRenderer
```typescript
interface ContentRendererProps {
content: string;
className?: string;
sanitize?: boolean;
processAssets?: boolean;
convertClasses?: boolean;
parsePatterns?: boolean;
pageSlug?: string; // NEW: For page-specific parsing
}
```
### Usage Examples
```tsx
// Home page
<ContentRenderer
content={page.contentHtml}
pageSlug="corporate-3-landing-2"
/>
// Terms page
<ContentRenderer
content={page.contentHtml}
pageSlug="terms"
/>
// Contact page
<ContentRenderer
content={page.contentHtml}
pageSlug="contact"
/>
```
## Tailwind Class Conversions
### WordPress Classes → Tailwind
- `vc_row``flex flex-wrap -mx-4`
- `vc_col-md-6``w-full md:w-1/2 px-4`
- `vc_col-lg-4``w-full lg:w-1/3 px-4`
- `wpb_wrapper``space-y-4`
- `bg-light``bg-gray-50`
- `btn-primary``bg-primary text-white hover:bg-primary-dark`
## Asset URL Replacement
All WordPress asset URLs are automatically replaced:
- `https://klz-cables.com/wp-content/uploads/...``/media/...`
- PDF links are mapped to local paths
- Images use Next.js Image component with optimization
## Testing Checklist
For each page type, verify:
- [ ] Hero sections render correctly
- [ ] Numbered features display properly
- [ ] Card grids are responsive
- [ ] Testimonials have proper styling
- [ ] Forms work correctly
- [ ] PDF links are accessible
- [ ] Contact info is formatted
- [ ] Nested rows don't cause duplication
- [ ] Empty rows are filtered out
- [ ] Asset URLs are replaced
- [ ] No raw HTML remains
## Performance Notes
- Parser uses Cheerio for server-side HTML parsing
- Recursive parsing handles nested structures efficiently
- Pattern matching stops at first match (priority order)
- Processed rows are removed to avoid duplication
- Remaining content is handled by fallback parser
## Future Enhancements
1. **Page-Specific Overrides**: Add `pageSlug` parameter for custom logic
2. **Animation Support**: Detect and convert Salient animation classes
3. **Background Images**: Handle data-bg-image attributes
4. **Video Backgrounds**: Support data-video-bg attributes
5. **Parallax Effects**: Convert to modern CSS or React libraries
6. **Icon Support**: Map Font Awesome or other icon classes
7. **Accordion/Toggle**: Detect and convert collapsible sections
8. **Tab Components**: Handle tabbed content sections
## Migration Status
**Completed:**
- Home pages (corporate-3-landing-2/start)
- Team pages (team)
- Terms pages (terms/agbs)
- Contact pages (contact/kontakt)
- Legal pages (legal-notice/impressum)
- Privacy pages (privacy-policy/datenschutz)
- Thanks pages (thanks/danke)
**Pending:**
- Blog pages (blog) - Uses page routing
- Products pages (products/produkte) - Uses page routing
## Notes
- All pages now use detailed component recreations
- No raw HTML leakage where components fit
- Parser is extensible for new patterns
- Documentation updated with per-page details
- System ready for testing and verification

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import Image from 'next/image';
import { Container } from '@/components/ui/Container';
import { Button } from '@/components/ui/Button';
import { Navigation } from './Navigation';
@@ -23,7 +24,16 @@ export function Header({ locale, siteName = 'KLZ Cables', logo }: HeaderProps) {
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
{logo ? (
<img src={logo} alt={siteName} className="h-8 w-auto" />
<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>
) : (
<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>
@@ -51,7 +61,7 @@ export function Header({ locale, siteName = 'KLZ Cables', logo }: HeaderProps) {
{/* Mobile Menu */}
<div className="flex items-center gap-2">
<MobileMenu locale={locale} siteName={siteName} />
<MobileMenu locale={locale} siteName={siteName} logo={logo} />
</div>
</div>
</Container>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
@@ -9,10 +10,11 @@ import { LocaleSwitcher } from '@/components/LocaleSwitcher';
interface MobileMenuProps {
locale: string;
siteName: string;
logo?: string;
onClose?: () => void;
}
export function MobileMenu({ locale, siteName, onClose }: MobileMenuProps) {
export function MobileMenu({ locale, siteName, logo, onClose }: MobileMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const pathname = usePathname();
@@ -99,9 +101,22 @@ export function MobileMenu({ locale, siteName, onClose }: MobileMenuProps) {
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 safe-area-p">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<span className="text-white font-bold text-sm">KLZ</span>
</div>
{logo ? (
<div className="relative w-10 h-10">
<Image
src={logo.replace(/^\//, '')}
alt={siteName}
fill
className="object-contain"
sizes="40px"
priority={false}
/>
</div>
) : (
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<span className="text-white font-bold text-sm">KLZ</span>
</div>
)}
<span className="font-semibold text-gray-900 text-lg">{siteName}</span>
</div>
<button

255
components/ui/Icon.tsx Normal file
View File

@@ -0,0 +1,255 @@
'use client';
import React from 'react';
import { cn } from '../../lib/utils';
import * as LucideIcons from 'lucide-react';
// Supported icon types
export type IconName =
// Lucide icons (primary)
| 'star' | 'check' | 'x' | 'arrow-left' | 'arrow-right' | 'chevron-left' | 'chevron-right'
| 'quote' | 'phone' | 'mail' | 'map-pin' | 'clock' | 'calendar' | 'user' | 'users'
| 'award' | 'briefcase' | 'building' | 'globe' | 'settings' | 'tool' | 'wrench'
| 'shield' | 'lock' | 'key' | 'heart' | 'thumbs-up' | 'message-circle' | 'phone-call'
| 'mail-open' | 'map' | 'navigation' | 'home' | 'info' | 'alert-circle' | 'check-circle'
| 'x-circle' | 'plus' | 'minus' | 'search' | 'filter' | 'download' | 'upload'
| 'share-2' | 'link' | 'external-link' | 'file-text' | 'file' | 'folder'
// Font Awesome style aliases (for WP compatibility)
| 'fa-star' | 'fa-check' | 'fa-times' | 'fa-arrow-left' | 'fa-arrow-right'
| 'fa-quote-left' | 'fa-phone' | 'fa-envelope' | 'fa-map-marker' | 'fa-clock-o'
| 'fa-calendar' | 'fa-user' | 'fa-users' | 'fa-trophy' | 'fa-briefcase'
| 'fa-building' | 'fa-globe' | 'fa-cog' | 'fa-wrench' | 'fa-shield'
| 'fa-lock' | 'fa-key' | 'fa-heart' | 'fa-thumbs-up' | 'fa-comment'
| 'fa-phone-square' | 'fa-envelope-open' | 'fa-map' | 'fa-compass'
| 'fa-home' | 'fa-info-circle' | 'fa-check-circle' | 'fa-times-circle'
| 'fa-plus' | 'fa-minus' | 'fa-search' | 'fa-filter' | 'fa-download'
| 'fa-upload' | 'fa-share-alt' | 'fa-link' | 'fa-external-link'
| 'fa-file-text' | 'fa-file' | 'fa-folder';
export interface IconProps {
name: IconName;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
className?: string;
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'muted' | 'current';
strokeWidth?: number;
onClick?: () => void;
ariaLabel?: string;
}
/**
* Icon Component
* Universal icon component supporting Lucide icons and Font Awesome aliases
* Maps WPBakery vc_icon patterns to modern React icons
*/
export const Icon: React.FC<IconProps> = ({
name,
size = 'md',
className = '',
color = 'current',
strokeWidth = 2,
onClick,
ariaLabel
}) => {
// Map size to actual dimensions
const sizeMap = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
xl: 'w-8 h-8',
'2xl': 'w-10 h-10'
};
// Map color to Tailwind classes
const colorMap = {
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-green-600',
warning: 'text-yellow-600',
error: 'text-red-600',
muted: 'text-gray-500',
current: 'text-current'
};
// Normalize icon name (remove fa- prefix and map to Lucide)
const normalizeIconName = (iconName: string): string => {
// Remove fa- prefix if present
const cleanName = iconName.replace(/^fa-/, '');
// Map common Font Awesome names to Lucide
const faToLucide: Record<string, string> = {
'star': 'star',
'check': 'check',
'times': 'x',
'arrow-left': 'arrow-left',
'arrow-right': 'arrow-right',
'quote-left': 'quote',
'phone': 'phone',
'envelope': 'mail',
'map-marker': 'map-pin',
'clock-o': 'clock',
'calendar': 'calendar',
'user': 'user',
'users': 'users',
'trophy': 'award',
'briefcase': 'briefcase',
'building': 'building',
'globe': 'globe',
'cog': 'settings',
'wrench': 'wrench',
'shield': 'shield',
'lock': 'lock',
'key': 'key',
'heart': 'heart',
'thumbs-up': 'thumbs-up',
'comment': 'message-circle',
'phone-square': 'phone',
'envelope-open': 'mail-open',
'map': 'map',
'compass': 'navigation',
'home': 'home',
'info-circle': 'info',
'check-circle': 'check-circle',
'times-circle': 'x-circle',
'plus': 'plus',
'minus': 'minus',
'search': 'search',
'filter': 'filter',
'download': 'download',
'upload': 'upload',
'share-alt': 'share-2',
'link': 'link',
'external-link': 'external-link',
'file-text': 'file-text',
'file': 'file',
'folder': 'folder'
};
return faToLucide[cleanName] || cleanName;
};
const iconName = normalizeIconName(name);
const IconComponent = (LucideIcons as any)[iconName];
if (!IconComponent) {
console.warn(`Icon "${name}" (normalized: "${iconName}") not found in Lucide icons`);
return (
<span className={cn(
'inline-flex items-center justify-center',
sizeMap[size],
colorMap[color],
'bg-gray-200 rounded',
className
)}>
?
</span>
);
}
return (
<IconComponent
className={cn(
'inline-block',
sizeMap[size],
colorMap[color],
'transition-transform duration-150',
onClick ? 'cursor-pointer hover:scale-110' : '',
className
)}
strokeWidth={strokeWidth}
onClick={onClick}
role={onClick ? 'button' : 'img'}
aria-label={ariaLabel || name}
tabIndex={onClick ? 0 : undefined}
/>
);
};
// Helper component for icon buttons
export const IconButton: React.FC<IconProps & { label?: string }> = ({
name,
size = 'md',
className = '',
color = 'primary',
onClick,
label,
ariaLabel
}) => {
return (
<button
onClick={onClick}
className={cn(
'inline-flex items-center justify-center gap-2',
'rounded-lg transition-all duration-200',
'hover:bg-primary/10 active:scale-95',
'focus:outline-none focus:ring-2 focus:ring-primary/50',
className
)}
aria-label={ariaLabel || label || name}
>
<Icon name={name} size={size} color={color} />
{label && <span className="text-sm font-medium">{label}</span>}
</button>
);
};
// Helper function to parse WPBakery vc_icon attributes
export function parseWpIcon(iconClass: string): IconProps {
// Parse classes like "vc_icon fa fa-star" or "vc_icon lucide-star"
const parts = iconClass.split(/\s+/);
let name: IconName = 'star';
let size: IconProps['size'] = 'md';
// Find icon name
const iconPart = parts.find(p => p.includes('fa-') || p.includes('lucide-') || p === 'fa');
if (iconPart) {
if (iconPart.includes('fa-')) {
name = iconPart.replace('fa-', '') as IconName;
} else if (iconPart.includes('lucide-')) {
name = iconPart.replace('lucide-', '') as IconName;
}
}
// Find size
if (parts.includes('fa-lg') || parts.includes('text-xl')) size = 'lg';
if (parts.includes('fa-2x')) size = 'xl';
if (parts.includes('fa-3x')) size = '2xl';
if (parts.includes('fa-xs')) size = 'xs';
if (parts.includes('fa-sm')) size = 'sm';
return { name, size };
}
// Icon wrapper for feature lists
export const IconFeature: React.FC<{
icon: IconName;
title: string;
description?: string;
iconPosition?: 'top' | 'left';
className?: string;
}> = ({ icon, title, description, iconPosition = 'left', className = '' }) => {
const isLeft = iconPosition === 'left';
return (
<div className={cn(
'flex gap-4',
isLeft ? 'flex-row items-start' : 'flex-col items-center text-center',
className
)}>
<Icon
name={icon}
size="xl"
color="primary"
className={cn(isLeft ? 'mt-1' : '')}
/>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{title}</h3>
{description && (
<p className="text-gray-600 text-sm leading-relaxed">{description}</p>
)}
</div>
</div>
);
};
export default Icon;

255
components/ui/Slider.tsx Normal file
View File

@@ -0,0 +1,255 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { cn } from '../../lib/utils';
import { ChevronLeft, ChevronRight } from 'lucide-react';
export interface Slide {
id: string;
title?: string;
subtitle?: string;
description?: string;
image?: string;
ctaText?: string;
ctaLink?: string;
}
export interface SliderProps {
slides: Slide[];
autoplay?: boolean;
autoplayInterval?: number;
showControls?: boolean;
showIndicators?: boolean;
variant?: 'default' | 'fullscreen' | 'compact';
className?: string;
}
/**
* Slider Component
* Responsive carousel for WPBakery nectar_slider/nectar_carousel patterns
* Supports autoplay, manual controls, and multiple variants
*/
export const Slider: React.FC<SliderProps> = ({
slides,
autoplay = false,
autoplayInterval = 5000,
showControls = true,
showIndicators = true,
variant = 'default',
className = ''
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
// Handle autoplay
useEffect(() => {
if (!autoplay || slides.length <= 1) return;
const interval = setInterval(() => {
nextSlide();
}, autoplayInterval);
return () => clearInterval(interval);
}, [autoplay, autoplayInterval, currentIndex, slides.length]);
const nextSlide = useCallback(() => {
if (isTransitioning || slides.length <= 1) return;
setIsTransitioning(true);
setCurrentIndex((prev) => (prev + 1) % slides.length);
setTimeout(() => setIsTransitioning(false), 300);
}, [slides.length, isTransitioning]);
const prevSlide = useCallback(() => {
if (isTransitioning || slides.length <= 1) return;
setIsTransitioning(true);
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
setTimeout(() => setIsTransitioning(false), 300);
}, [slides.length, isTransitioning]);
const goToSlide = useCallback((index: number) => {
if (isTransitioning || slides.length <= 1) return;
setIsTransitioning(true);
setCurrentIndex(index);
setTimeout(() => setIsTransitioning(false), 300);
}, [slides.length, isTransitioning]);
// Variant-specific styles
const variantStyles = {
default: 'rounded-xl overflow-hidden shadow-lg',
fullscreen: 'w-full h-full rounded-none',
compact: 'rounded-lg overflow-hidden shadow-md'
};
const heightStyles = {
default: 'h-96 md:h-[500px]',
fullscreen: 'h-screen',
compact: 'h-64 md:h-80'
};
return (
<div className={cn(
'relative w-full bg-gray-900',
heightStyles[variant],
variantStyles[variant],
className
)}>
{/* Slides Container */}
<div className="relative w-full h-full overflow-hidden">
{slides.map((slide, index) => (
<div
key={slide.id}
className={cn(
'absolute inset-0 w-full h-full transition-opacity duration-500',
currentIndex === index ? 'opacity-100 z-10' : 'opacity-0 z-0'
)}
>
{/* Background Image */}
{slide.image && (
<div className="absolute inset-0">
<div
className="w-full h-full bg-cover bg-center"
style={{ backgroundImage: `url(${slide.image})` }}
/>
{/* Overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/20 to-black/60" />
</div>
)}
{/* Content */}
<div className="relative z-10 h-full flex flex-col items-center justify-center px-4 md:px-8 text-white text-center">
<div className="max-w-4xl space-y-4 md:space-y-6">
{slide.subtitle && (
<p className={cn(
'text-sm md:text-base uppercase tracking-wider font-semibold',
'text-white/90',
variant === 'compact' && 'text-xs'
)}>
{slide.subtitle}
</p>
)}
{slide.title && (
<h2 className={cn(
'text-3xl md:text-5xl font-bold leading-tight',
'text-white drop-shadow-lg',
variant === 'compact' && 'text-2xl md:text-3xl'
)}>
{slide.title}
</h2>
)}
{slide.description && (
<p className={cn(
'text-lg md:text-xl leading-relaxed',
'text-white/90 max-w-2xl mx-auto',
variant === 'compact' && 'text-base md:text-lg'
)}>
{slide.description}
</p>
)}
{slide.ctaText && slide.ctaLink && (
<a
href={slide.ctaLink}
className={cn(
'inline-flex items-center justify-center',
'px-6 py-3 md:px-8 md:py-4',
'bg-primary hover:bg-primary-dark',
'text-white font-semibold rounded-lg',
'transition-all duration-200',
'hover:scale-105 active:scale-95',
'shadow-lg hover:shadow-xl'
)}
>
{slide.ctaText}
</a>
)}
</div>
</div>
</div>
))}
</div>
{/* Navigation Controls */}
{showControls && slides.length > 1 && (
<>
<button
onClick={prevSlide}
className={cn(
'absolute left-4 top-1/2 -translate-y-1/2',
'z-20 p-2 md:p-3',
'bg-white/20 hover:bg-white/30 backdrop-blur-sm',
'text-white rounded-full',
'transition-all duration-200',
'hover:scale-110 active:scale-95',
'focus:outline-none focus:ring-2 focus:ring-white/50'
)}
aria-label="Previous slide"
>
<ChevronLeft className="w-5 h-5 md:w-6 md:h-6" />
</button>
<button
onClick={nextSlide}
className={cn(
'absolute right-4 top-1/2 -translate-y-1/2',
'z-20 p-2 md:p-3',
'bg-white/20 hover:bg-white/30 backdrop-blur-sm',
'text-white rounded-full',
'transition-all duration-200',
'hover:scale-110 active:scale-95',
'focus:outline-none focus:ring-2 focus:ring-white/50'
)}
aria-label="Next slide"
>
<ChevronRight className="w-5 h-5 md:w-6 md:h-6" />
</button>
</>
)}
{/* Indicators */}
{showIndicators && slides.length > 1 && (
<div className={cn(
'absolute bottom-4 left-1/2 -translate-x-1/2',
'z-20 flex gap-2',
'bg-black/20 backdrop-blur-sm px-3 py-2 rounded-full'
)}>
{slides.map((_, index) => (
<button
key={index}
onClick={() => goToSlide(index)}
className={cn(
'w-2 h-2 md:w-3 md:h-3 rounded-full',
'transition-all duration-200',
currentIndex === index
? 'bg-white scale-125'
: 'bg-white/40 hover:bg-white/60 hover:scale-110'
)}
aria-label={`Go to slide ${index + 1}`}
aria-current={currentIndex === index}
/>
))}
</div>
)}
{/* Slide Counter (optional, for accessibility) */}
<div className={cn(
'absolute top-4 right-4',
'z-20 px-3 py-1',
'bg-black/30 backdrop-blur-sm',
'text-white text-sm font-medium rounded-full'
)}>
{currentIndex + 1} / {slides.length}
</div>
</div>
);
};
// Helper function to convert WPBakery slider HTML to Slide array
export function parseWpSlider(content: string): Slide[] {
// This would parse nectar_slider or similar WPBakery slider patterns
// For now, returns empty array - can be enhanced based on actual WP content
return [];
}
export default Slider;

View File

@@ -23,13 +23,15 @@ export {
type BadgeSize,
type BadgeGroupProps
} from './Badge';
export {
Loading,
LoadingButton,
LoadingSkeleton,
type LoadingProps,
type LoadingSize,
type LoadingVariant,
type LoadingButtonProps,
type LoadingSkeletonProps
} from './Loading';
export {
Loading,
LoadingButton,
LoadingSkeleton,
type LoadingProps,
type LoadingSize,
type LoadingVariant,
type LoadingButtonProps,
type LoadingSkeletonProps
} from './Loading';
export { Slider, type Slide, type SliderProps, parseWpSlider } from './Slider';
export { Icon, IconButton, IconFeature, parseWpIcon, type IconProps, type IconName } from './Icon';