migration wip
This commit is contained in:
224
components/cards/TestimonialCard.tsx
Normal file
224
components/cards/TestimonialCard.tsx
Normal 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;
|
||||
@@ -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
359
components/content/WPBakeryMapping.md
Normal file
359
components/content/WPBakeryMapping.md
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
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