migration wip
This commit is contained in:
255
components/ui/Icon.tsx
Normal file
255
components/ui/Icon.tsx
Normal 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
255
components/ui/Slider.tsx
Normal 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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user