cleanup
This commit is contained in:
@@ -1,225 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { getDictionary } from '@/lib/i18n';
|
||||
import { Card, CardBody, CardHeader, Button } from '@/components/ui';
|
||||
import { FormField, FormInput, FormTextarea, FormError, FormSuccess } from '@/components/forms';
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ContactForm() {
|
||||
const pathname = usePathname();
|
||||
const locale = pathname.split('/')[1] || 'en';
|
||||
const [dict, setDict] = useState<any>({});
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [errors, setErrors] = useState<Partial<FormData>>({});
|
||||
|
||||
// Load dictionary on component mount and when locale changes
|
||||
useEffect(() => {
|
||||
const loadDict = async () => {
|
||||
try {
|
||||
const loadedDict = await getDictionary(locale as 'en' | 'de');
|
||||
setDict(loadedDict);
|
||||
} catch (error) {
|
||||
console.error('Error loading dictionary:', error);
|
||||
// Set empty dictionary to prevent infinite loading
|
||||
setDict({});
|
||||
}
|
||||
};
|
||||
loadDict();
|
||||
}, [locale]);
|
||||
|
||||
const t = (key: string): string => {
|
||||
if (!dict || Object.keys(dict).length === 0) return key;
|
||||
|
||||
const keys = key.split('.');
|
||||
let value: any = dict;
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k];
|
||||
if (value === undefined) return key;
|
||||
}
|
||||
|
||||
return value || key;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<FormData> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t('contact.errors.nameRequired');
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = t('contact.errors.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = t('contact.errors.emailInvalid');
|
||||
}
|
||||
|
||||
if (!formData.message.trim()) {
|
||||
newErrors.message = t('contact.errors.messageRequired');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/${locale}/api/contact`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setStatus('success');
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof FormData]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="elevated" padding="lg">
|
||||
<CardHeader
|
||||
title={t('contact.title')}
|
||||
subtitle={t('contact.subtitle')}
|
||||
/>
|
||||
<CardBody>
|
||||
<FormSuccess message={status === 'success' ? t('contact.success') : undefined} />
|
||||
<FormError errors={status === 'error' ? t('contact.error') : undefined} />
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
name="name"
|
||||
label={t('contact.name')}
|
||||
required
|
||||
error={errors.name}
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
|
||||
disabled={status === 'loading'}
|
||||
placeholder={t('contact.name')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
label={t('contact.email')}
|
||||
required
|
||||
error={errors.email}
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, email: value }))}
|
||||
disabled={status === 'loading'}
|
||||
placeholder={t('contact.email')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
name="phone"
|
||||
label={t('contact.phone')}
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, phone: value }))}
|
||||
disabled={status === 'loading'}
|
||||
placeholder={t('contact.phone')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="subject"
|
||||
label={t('contact.subject')}
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, subject: value }))}
|
||||
disabled={status === 'loading'}
|
||||
placeholder={t('contact.subject')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="message"
|
||||
label={t('contact.message')}
|
||||
required
|
||||
error={errors.message}
|
||||
type="textarea"
|
||||
value={formData.message}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, message: value }))}
|
||||
disabled={status === 'loading'}
|
||||
placeholder={t('contact.message')}
|
||||
rows={6}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('contact.requiredFields')}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={status === 'loading'}
|
||||
disabled={status === 'loading'}
|
||||
>
|
||||
{status === 'loading' ? t('contact.sending') : t('contact.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { t, getLocaleFromPath } from '@/lib/i18n';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Card, CardBody, CardFooter } from '@/components/ui';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
export function CookieConsent() {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const locale = getLocaleFromPath(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const consent = localStorage.getItem('cookie-consent');
|
||||
if (!consent) {
|
||||
// Small delay to ensure smooth entrance animation
|
||||
setTimeout(() => {
|
||||
setShowBanner(true);
|
||||
setIsAnimating(true);
|
||||
}, 500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookie-consent', 'accepted');
|
||||
setIsAnimating(false);
|
||||
setTimeout(() => setShowBanner(false), 300);
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem('cookie-consent', 'declined');
|
||||
setIsAnimating(false);
|
||||
setTimeout(() => setShowBanner(false), 300);
|
||||
};
|
||||
|
||||
if (!isMounted || !showBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-0 left-0 right-0 z-50 px-4 pb-4 md:pb-6 transition-all duration-300 ${
|
||||
isAnimating ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
|
||||
}`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Card
|
||||
variant="elevated"
|
||||
padding="md"
|
||||
className="border-primary/20 shadow-xl"
|
||||
>
|
||||
<CardBody>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
{t('cookieConsent.message', locale)}{' '}
|
||||
<a
|
||||
href="/privacy-policy"
|
||||
className="text-primary hover:text-primary-dark underline ml-1 font-medium transition-colors"
|
||||
>
|
||||
{t('cookieConsent.privacyPolicy', locale)}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 w-full md:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDecline}
|
||||
className="flex-1 md:flex-none"
|
||||
>
|
||||
{t('cookieConsent.decline', locale)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAccept}
|
||||
className="flex-1 md:flex-none"
|
||||
>
|
||||
{t('cookieConsent.accept', locale)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { getLocaleFromPath, getLocalizedPath, type Locale } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const pathname = usePathname();
|
||||
const currentLocale = getLocaleFromPath(pathname);
|
||||
|
||||
const locales: Locale[] = ['en', 'de'];
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 bg-white rounded-lg border border-gray-200 p-1 shadow-sm">
|
||||
{locales.map((locale) => {
|
||||
const isActive = currentLocale === locale;
|
||||
const label = locale === 'en' ? 'English' : 'Deutsch';
|
||||
const flag = locale === 'en' ? '🇺🇸' : '🇩🇪';
|
||||
|
||||
// Get the current path without locale
|
||||
const currentPath = pathname.replace(/^\/(de|en)/, '') || '/';
|
||||
const href = getLocalizedPath(currentPath, locale);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={locale}
|
||||
href={href}
|
||||
locale={locale}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
variant={isActive ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
className={`transition-all ${isActive ? 'shadow-sm' : ''}`}
|
||||
aria-label={`Switch to ${label}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="text-base">{flag}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { getLocaleFromPath } from '@/lib/i18n'
|
||||
|
||||
interface NavigationProps {
|
||||
logo?: string
|
||||
siteName: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use components/layout/Navigation instead
|
||||
*/
|
||||
export function Navigation({ logo, siteName, locale }: NavigationProps) {
|
||||
const pathname = usePathname()
|
||||
const currentLocale = getLocaleFromPath(pathname)
|
||||
|
||||
// Static menu for now - can be made dynamic later
|
||||
const mainMenu = [
|
||||
{ title: 'Home', path: `/${locale}` },
|
||||
{ title: 'Blog', path: `/${locale}/blog` },
|
||||
{ title: 'Products', path: `/${locale}/products` },
|
||||
{ title: 'Contact', path: `/${locale}/contact` }
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<Link href={`/${locale}`} className="text-xl font-bold text-primary">
|
||||
{logo || siteName}
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{mainMenu.map((item) => {
|
||||
const isActive = pathname === item.path ||
|
||||
(item.path !== `/${locale}` && pathname.startsWith(item.path))
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'text-primary bg-primary-light font-semibold'
|
||||
: 'text-gray-700 hover:text-primary hover:bg-primary-light'
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
{isActive && (
|
||||
<span className="block h-0.5 bg-primary rounded-full mt-1" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Product } from '@/lib/data';
|
||||
import { ProductCard } from '@/components/cards/ProductCard';
|
||||
import { CardGrid } from '@/components/cards/CardGrid';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
interface ProductListProps {
|
||||
products: Product[];
|
||||
locale?: 'de' | 'en';
|
||||
enableFiltering?: boolean;
|
||||
enableSorting?: boolean;
|
||||
enablePagination?: boolean;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
export function ProductList({
|
||||
products,
|
||||
locale = 'de',
|
||||
enableFiltering = false,
|
||||
enableSorting = false,
|
||||
enablePagination = false,
|
||||
itemsPerPage = 12
|
||||
}: ProductListProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [filterStock, setFilterStock] = useState<'all' | 'instock' | 'outofstock'>('all');
|
||||
|
||||
// Filter products
|
||||
const filteredProducts = useMemo(() => {
|
||||
let filtered = [...products];
|
||||
|
||||
// Filter by stock status
|
||||
if (filterStock !== 'all') {
|
||||
filtered = filtered.filter(p => p.stockStatus === filterStock);
|
||||
}
|
||||
|
||||
// Sort products
|
||||
if (enableSorting) {
|
||||
filtered.sort((a, b) => {
|
||||
const aPrice = parseFloat(a.regularPrice?.replace(/[^\d.]/g, '') || '0');
|
||||
const bPrice = parseFloat(b.regularPrice?.replace(/[^\d.]/g, '') || '0');
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aPrice - bPrice;
|
||||
} else {
|
||||
return bPrice - aPrice;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [products, filterStock, sortOrder, enableSorting]);
|
||||
|
||||
// Pagination
|
||||
const paginatedProducts = useMemo(() => {
|
||||
if (!enablePagination) return filteredProducts;
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return filteredProducts.slice(startIndex, endIndex);
|
||||
}, [filteredProducts, currentPage, itemsPerPage, enablePagination]);
|
||||
|
||||
const totalPages = enablePagination ? Math.ceil(filteredProducts.length / itemsPerPage) : 1;
|
||||
|
||||
// Handlers
|
||||
const handleSortChange = () => {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
const handleFilterChange = (status: 'all' | 'instock' | 'outofstock') => {
|
||||
setFilterStock(status);
|
||||
setCurrentPage(1); // Reset to first page
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Loading state (for future use)
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">
|
||||
{locale === 'de' ? 'Keine Produkte gefunden' : 'No products found'}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
{locale === 'de'
|
||||
? 'Es wurden keine Produkte für diese Kategorie gefunden.'
|
||||
: 'No products were found for this category.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Controls */}
|
||||
{(enableFiltering || enableSorting) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{enableFiltering && (
|
||||
<div className="flex gap-2" role="group" aria-label="Stock filter">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterStock === 'all' ? 'primary' : 'outline'}
|
||||
onClick={() => handleFilterChange('all')}
|
||||
>
|
||||
{locale === 'de' ? 'Alle' : 'All'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterStock === 'instock' ? 'primary' : 'outline'}
|
||||
onClick={() => handleFilterChange('instock')}
|
||||
>
|
||||
{locale === 'de' ? 'Auf Lager' : 'In Stock'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filterStock === 'outofstock' ? 'primary' : 'outline'}
|
||||
onClick={() => handleFilterChange('outofstock')}
|
||||
>
|
||||
{locale === 'de' ? 'Nicht auf Lager' : 'Out of Stock'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enableSorting && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleSortChange}
|
||||
icon={
|
||||
<span className="text-xs">
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{locale === 'de'
|
||||
? `Preis: ${sortOrder === 'asc' ? 'Aufsteigend' : 'Absteigend'}`
|
||||
: `Price: ${sortOrder === 'asc' ? 'Ascending' : 'Descending'}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{locale === 'de'
|
||||
? `${filteredProducts.length} Produkte gefunden`
|
||||
: `${filteredProducts.length} products found`}
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
<CardGrid
|
||||
loading={isLoading}
|
||||
emptyMessage={locale === 'de' ? 'Keine Produkte gefunden' : 'No products found'}
|
||||
columns={3}
|
||||
gap="md"
|
||||
>
|
||||
{paginatedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
locale={locale}
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
showCategories={true}
|
||||
showAddToCart={true}
|
||||
showViewDetails={false}
|
||||
size="md"
|
||||
/>
|
||||
))}
|
||||
</CardGrid>
|
||||
|
||||
{/* Pagination */}
|
||||
{enablePagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
{locale === 'de' ? 'Vorherige' : 'Previous'}
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
size="sm"
|
||||
variant={currentPage === page ? 'primary' : 'outline'}
|
||||
onClick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
{locale === 'de' ? 'Nächste' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { getSiteInfo } from '@/lib/i18n';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
interface SEOProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
locale?: 'en' | 'de';
|
||||
path?: string;
|
||||
type?: 'website' | 'article' | 'product';
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
authors?: string[];
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export function SEO({
|
||||
title,
|
||||
description,
|
||||
locale = 'en',
|
||||
path = '/',
|
||||
type = 'website',
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
authors,
|
||||
images
|
||||
}: SEOProps): ReactElement {
|
||||
const site = getSiteInfo();
|
||||
const fullTitle = title === 'Home' ? site.title : `${title} | ${site.title}`;
|
||||
const fullDescription = description || site.description;
|
||||
const canonicalUrl = `${site.baseUrl}${path}`;
|
||||
|
||||
// Generate alternate URLs
|
||||
const alternateLocale = locale === 'en' ? 'de' : 'en';
|
||||
const alternatePath = path === '/' ? '' : path;
|
||||
const alternateUrl = `${site.baseUrl}/${alternateLocale}${alternatePath}`;
|
||||
|
||||
// Open Graph images
|
||||
const ogImages = images && images.length > 0
|
||||
? images
|
||||
: [`${site.baseUrl}/og-image.jpg`];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Basic Meta Tags */}
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={fullDescription} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
{/* Alternate Languages */}
|
||||
<link rel="alternate" hrefLang={locale} href={canonicalUrl} />
|
||||
<link rel="alternate" hrefLang={alternateLocale} href={alternateUrl} />
|
||||
<link rel="alternate" hrefLang="x-default" href={`${site.baseUrl}${alternatePath}`} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={fullDescription} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:locale" content={locale === 'en' ? 'en_US' : 'de_DE'} />
|
||||
<meta property="og:site_name" content={site.title} />
|
||||
|
||||
{ogImages.map((image, index) => (
|
||||
<meta key={index} property="og:image" content={image} />
|
||||
))}
|
||||
|
||||
{publishedTime && (
|
||||
<meta property="article:published_time" content={publishedTime} />
|
||||
)}
|
||||
|
||||
{modifiedTime && (
|
||||
<meta property="article:modified_time" content={modifiedTime} />
|
||||
)}
|
||||
|
||||
{authors && authors.length > 0 && (
|
||||
<meta property="article:author" content={authors.join(', ')} />
|
||||
)}
|
||||
|
||||
{/* Twitter Card */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={fullDescription} />
|
||||
{ogImages[0] && (
|
||||
<meta name="twitter:image" content={ogImages[0]} />
|
||||
)}
|
||||
|
||||
{/* Site Info */}
|
||||
<meta name="author" content="KLZ Kabelwerke" />
|
||||
<meta name="copyright" content="KLZ Kabelwerke" />
|
||||
|
||||
{/* Favicon (placeholder) */}
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateSEOMetadata(props: SEOProps) {
|
||||
const site = getSiteInfo();
|
||||
const fullTitle = props.title === 'Home' ? site.title : `${props.title} | ${site.title}`;
|
||||
const description = props.description || site.description;
|
||||
const canonicalUrl = `${site.baseUrl}${props.path || '/'}`;
|
||||
|
||||
const alternateLocale = props.locale === 'en' ? 'de' : 'en';
|
||||
const alternatePath = props.path && props.path !== '/' ? props.path : '';
|
||||
const alternateUrl = `${site.baseUrl}/${alternateLocale}${alternatePath}`;
|
||||
|
||||
return {
|
||||
title: fullTitle,
|
||||
description,
|
||||
metadataBase: new URL(site.baseUrl),
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
languages: {
|
||||
[props.locale || 'en']: canonicalUrl,
|
||||
[alternateLocale]: alternateUrl,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: fullTitle,
|
||||
description,
|
||||
type: props.type || 'website',
|
||||
locale: props.locale || 'en',
|
||||
siteName: site.title,
|
||||
url: canonicalUrl,
|
||||
...(props.images && props.images.length > 0 && {
|
||||
images: props.images.map(img => ({ url: img, alt: fullTitle })),
|
||||
}),
|
||||
...(props.publishedTime && { publishedTime: props.publishedTime }),
|
||||
...(props.modifiedTime && { modifiedTime: props.modifiedTime }),
|
||||
...(props.authors && { authors: props.authors }),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: fullTitle,
|
||||
description,
|
||||
...(props.images && props.images[0] && { images: [props.images[0]] }),
|
||||
},
|
||||
authors: props.authors ? props.authors.map(name => ({ name })) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode, HTMLAttributes, forwardRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card } from '@/components/ui';
|
||||
|
||||
// Base card sizes
|
||||
export type CardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Base card layouts
|
||||
export type CardLayout = 'vertical' | 'horizontal';
|
||||
|
||||
// Base card props interface
|
||||
export interface BaseCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
/** Card title */
|
||||
title?: ReactNode;
|
||||
/** Card description/excerpt */
|
||||
description?: ReactNode;
|
||||
/** Card image URL */
|
||||
image?: string;
|
||||
/** Card image alt text */
|
||||
imageAlt?: string;
|
||||
/** Card size */
|
||||
size?: CardSize;
|
||||
/** Card layout */
|
||||
layout?: CardLayout;
|
||||
/** Card href/link */
|
||||
href?: string;
|
||||
/** Card badge/badges */
|
||||
badge?: ReactNode;
|
||||
/** Card footer content */
|
||||
footer?: ReactNode;
|
||||
/** Card header content */
|
||||
header?: ReactNode;
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Hover effect */
|
||||
hoverable?: boolean;
|
||||
/** Card variant */
|
||||
variant?: 'elevated' | 'flat' | 'bordered';
|
||||
/** Image height */
|
||||
imageHeight?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Children content */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Helper function to get size styles
|
||||
const getSizeStyles = (size: CardSize, layout: CardLayout) => {
|
||||
const sizeMap = {
|
||||
vertical: {
|
||||
sm: { container: 'max-w-xs', image: 'h-32', padding: 'p-3' },
|
||||
md: { container: 'max-w-sm', image: 'h-48', padding: 'p-4' },
|
||||
lg: { container: 'max-w-md', image: 'h-64', padding: 'p-6' },
|
||||
},
|
||||
horizontal: {
|
||||
sm: { container: 'max-w-sm', image: 'h-24 w-24', padding: 'p-3' },
|
||||
md: { container: 'max-w-lg', image: 'h-32 w-32', padding: 'p-4' },
|
||||
lg: { container: 'max-w-xl', image: 'h-40 w-40', padding: 'p-6' },
|
||||
},
|
||||
};
|
||||
|
||||
return sizeMap[layout][size];
|
||||
};
|
||||
|
||||
// Helper function to get image height
|
||||
const getImageHeight = (height: CardSize | 'sm' | 'md' | 'lg' | 'xl') => {
|
||||
const heightMap = {
|
||||
sm: 'h-32',
|
||||
md: 'h-48',
|
||||
lg: 'h-64',
|
||||
xl: 'h-80',
|
||||
};
|
||||
return heightMap[height] || heightMap['md'];
|
||||
};
|
||||
|
||||
// Skeleton loader component
|
||||
const CardSkeleton = ({ layout, size }: { layout: CardLayout; size: CardSize }) => {
|
||||
const sizeStyles = getSizeStyles(size, layout);
|
||||
|
||||
return (
|
||||
<div className={cn('animate-pulse bg-gray-200 rounded-lg', sizeStyles.container)}>
|
||||
<div className={cn('bg-gray-300 rounded-t-lg', sizeStyles.image)} />
|
||||
<div className={sizeStyles.padding}>
|
||||
<div className="h-6 bg-gray-300 rounded mb-2 w-3/4" />
|
||||
<div className="h-4 bg-gray-300 rounded mb-1 w-full" />
|
||||
<div className="h-4 bg-gray-300 rounded mb-1 w-5/6" />
|
||||
<div className="h-4 bg-gray-300 rounded w-2/3 mt-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main BaseCard Component
|
||||
export const BaseCard = forwardRef<HTMLDivElement, BaseCardProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageAlt = '',
|
||||
size = 'md',
|
||||
layout = 'vertical',
|
||||
href,
|
||||
badge,
|
||||
footer,
|
||||
header,
|
||||
loading = false,
|
||||
hoverable = true,
|
||||
variant = 'elevated',
|
||||
imageHeight,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const sizeStyles = getSizeStyles(size, layout);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return <CardSkeleton layout={layout} size={size} />;
|
||||
}
|
||||
|
||||
// Content sections
|
||||
const renderImage = () => {
|
||||
if (!image) return null;
|
||||
|
||||
const imageClasses = layout === 'horizontal'
|
||||
? cn('flex-shrink-0 overflow-hidden rounded-lg', sizeStyles.image)
|
||||
: cn('w-full overflow-hidden rounded-t-lg', imageHeight ? getImageHeight(imageHeight) : sizeStyles.image);
|
||||
|
||||
return (
|
||||
<div className={imageClasses}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
if (!header && !badge) return null;
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
{header}
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTitle = () => {
|
||||
if (!title) return null;
|
||||
return (
|
||||
<h3 className={cn(
|
||||
'font-semibold text-gray-900 leading-tight',
|
||||
size === 'sm' && 'text-base',
|
||||
size === 'md' && 'text-lg',
|
||||
size === 'lg' && 'text-xl'
|
||||
)}>
|
||||
{title}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDescription = () => {
|
||||
if (!description) return null;
|
||||
return (
|
||||
<div className={cn(
|
||||
'text-gray-600 mt-1',
|
||||
size === 'sm' && 'text-sm',
|
||||
size === 'md' && 'text-sm',
|
||||
size === 'lg' && 'text-base'
|
||||
)}>
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!footer) return null;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Card content
|
||||
const cardContent = (
|
||||
<div className={cn(
|
||||
'flex',
|
||||
layout === 'horizontal' && 'flex-row',
|
||||
layout === 'vertical' && 'flex-col',
|
||||
sizeStyles.padding
|
||||
)}>
|
||||
{layout === 'horizontal' && renderImage()}
|
||||
<div className={cn('flex-1', layout === 'horizontal' && 'ml-4')}>
|
||||
{renderHeader()}
|
||||
{renderTitle()}
|
||||
{renderDescription()}
|
||||
{children}
|
||||
{renderFooter()}
|
||||
</div>
|
||||
{layout === 'vertical' && renderImage()}
|
||||
</div>
|
||||
);
|
||||
|
||||
// If href is provided, wrap in a Next.js Link
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'group block',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
hoverable && 'hover:-translate-y-1 hover:shadow-xl',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Card variant={variant} padding="none" className={sizeStyles.container}>
|
||||
{cardContent}
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, just return the card
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
padding="none"
|
||||
className={cn(sizeStyles.container, className)}
|
||||
hoverable={hoverable}
|
||||
{...props}
|
||||
>
|
||||
{cardContent}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BaseCard.displayName = 'BaseCard';
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { BaseCard, BaseCardProps, CardSize, CardLayout } from './BaseCard';
|
||||
import { Badge, BadgeGroup } from '@/components/ui';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { Post } from '@/lib/data';
|
||||
|
||||
// BlogCard specific props
|
||||
export interface BlogCardProps extends Omit<BaseCardProps, 'title' | 'description' | 'image' | 'footer'> {
|
||||
/** Post data from WordPress */
|
||||
post: Post;
|
||||
/** Display date */
|
||||
showDate?: boolean;
|
||||
/** Display categories */
|
||||
showCategories?: boolean;
|
||||
/** Read more text */
|
||||
readMoreText?: string;
|
||||
/** Excerpt length */
|
||||
excerptLength?: number;
|
||||
/** Locale for formatting */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// Helper to extract categories from post (mock implementation since Post doesn't have categories)
|
||||
const getPostCategories = (post: Post): string[] => {
|
||||
// In a real implementation, this would come from the post data
|
||||
// For now, return a mock category based on post ID
|
||||
return post.id % 2 === 0 ? ['News', 'Updates'] : ['Blog', 'Tips'];
|
||||
};
|
||||
|
||||
// Helper to get featured image URL
|
||||
const getFeaturedImageUrl = (post: Post): string | undefined => {
|
||||
// In a real implementation, this would use getMediaById
|
||||
// For now, return a placeholder or the featured image if available
|
||||
if (post.featuredImage) {
|
||||
// This would be resolved through the data layer
|
||||
return `/media/${post.featuredImage}.jpg`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Helper to truncate text
|
||||
const truncateText = (text: string, length: number): string => {
|
||||
if (text.length <= length) return text;
|
||||
return text.slice(0, length - 3) + '...';
|
||||
};
|
||||
|
||||
export const BlogCard: React.FC<BlogCardProps> = ({
|
||||
post,
|
||||
size = 'md',
|
||||
layout = 'vertical',
|
||||
showDate = true,
|
||||
showCategories = true,
|
||||
readMoreText = 'Read More',
|
||||
excerptLength = 150,
|
||||
locale = 'de',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
// Get post data
|
||||
const title = post.title;
|
||||
const excerpt = post.excerptHtml ? post.excerptHtml.replace(/<[^>]*>/g, '') : '';
|
||||
const truncatedExcerpt = truncateText(excerpt, excerptLength);
|
||||
const featuredImageUrl = getFeaturedImageUrl(post);
|
||||
const categories = showCategories ? getPostCategories(post) : [];
|
||||
const date = showDate ? formatDate(post.datePublished, locale === 'de' ? 'de-DE' : 'en-US') : '';
|
||||
|
||||
// Build badge component for categories
|
||||
const badge = showCategories && categories.length > 0 ? (
|
||||
<BadgeGroup gap="xs">
|
||||
{categories.map((category, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="neutral"
|
||||
size={size === 'sm' ? 'sm' : 'md'}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</BadgeGroup>
|
||||
) : null;
|
||||
|
||||
// Build header with date
|
||||
const header = date ? (
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{date}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// Build footer with read more link
|
||||
const footer = (
|
||||
<span className="text-sm font-medium text-primary hover:text-primary-dark transition-colors">
|
||||
{readMoreText} →
|
||||
</span>
|
||||
);
|
||||
|
||||
// Build description
|
||||
const description = truncatedExcerpt ? (
|
||||
<div
|
||||
className="text-gray-600"
|
||||
dangerouslySetInnerHTML={{ __html: truncatedExcerpt }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={title}
|
||||
description={description}
|
||||
image={featuredImageUrl}
|
||||
imageAlt={title}
|
||||
size={size}
|
||||
layout={layout}
|
||||
href={post.path}
|
||||
badge={badge}
|
||||
header={header}
|
||||
footer={footer}
|
||||
hoverable={true}
|
||||
variant="elevated"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// BlogCard variations
|
||||
export const BlogCardVertical: React.FC<BlogCardProps> = (props) => (
|
||||
<BlogCard {...props} layout="vertical" />
|
||||
);
|
||||
|
||||
export const BlogCardHorizontal: React.FC<BlogCardProps> = (props) => (
|
||||
<BlogCard {...props} layout="horizontal" />
|
||||
);
|
||||
|
||||
export const BlogCardSmall: React.FC<BlogCardProps> = (props) => (
|
||||
<BlogCard {...props} size="sm" />
|
||||
);
|
||||
|
||||
export const BlogCardLarge: React.FC<BlogCardProps> = (props) => (
|
||||
<BlogCard {...props} size="lg" />
|
||||
);
|
||||
|
||||
// Export types
|
||||
export type { CardSize, CardLayout };
|
||||
@@ -1,232 +0,0 @@
|
||||
# Card Components Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully created a comprehensive collection of specialized card components for displaying different content types from WordPress. These components provide consistent layouts across the site and replace the previous ProductList component.
|
||||
|
||||
## Files Created
|
||||
|
||||
### Core Components (5 files)
|
||||
1. **BaseCard.tsx** (6,489 bytes) - Foundation component
|
||||
2. **BlogCard.tsx** (4,074 bytes) - Blog post cards
|
||||
3. **ProductCard.tsx** (6,765 bytes) - Product cards
|
||||
4. **CategoryCard.tsx** (6,221 bytes) - Category cards
|
||||
5. **CardGrid.tsx** (4,466 bytes) - Grid wrapper
|
||||
|
||||
### Supporting Files (3 files)
|
||||
6. **index.ts** (910 bytes) - Component exports
|
||||
7. **CardsExample.tsx** (14,967 bytes) - Comprehensive usage examples
|
||||
8. **README.md** (8,282 bytes) - Documentation
|
||||
|
||||
## Component Features
|
||||
|
||||
### BaseCard
|
||||
- ✅ Multiple sizes (sm, md, lg)
|
||||
- ✅ Multiple layouts (vertical, horizontal)
|
||||
- ✅ Hover effects
|
||||
- ✅ Loading states
|
||||
- ✅ Image optimization support
|
||||
- ✅ Flexible content structure
|
||||
- ✅ Link wrapping
|
||||
- ✅ Custom variants
|
||||
|
||||
### BlogCard
|
||||
- ✅ Featured image display
|
||||
- ✅ Post title and excerpt
|
||||
- ✅ Publication date
|
||||
- ✅ Category badges
|
||||
- ✅ Read more links
|
||||
- ✅ Hover effects
|
||||
- ✅ Multiple sizes and layouts
|
||||
- ✅ Internationalization support
|
||||
|
||||
### ProductCard
|
||||
- ✅ Product image gallery
|
||||
- ✅ Multiple images with hover swap
|
||||
- ✅ Product name and description
|
||||
- ✅ Price display (regular/sale)
|
||||
- ✅ Stock status indicators
|
||||
- ✅ Category badges
|
||||
- ✅ Add to cart button
|
||||
- ✅ View details button
|
||||
- ✅ SKU display
|
||||
- ✅ Hover effects
|
||||
|
||||
### CategoryCard
|
||||
- ✅ Category image or icon
|
||||
- ✅ Category name and description
|
||||
- ✅ Product count
|
||||
- ✅ Link to category page
|
||||
- ✅ Multiple sizes and layouts
|
||||
- ✅ Icon-only variant
|
||||
- ✅ Support for product and blog categories
|
||||
|
||||
### CardGrid
|
||||
- ✅ Responsive grid (1-4 columns)
|
||||
- ✅ Configurable gap spacing
|
||||
- ✅ Loading skeleton states
|
||||
- ✅ Empty state handling
|
||||
- ✅ Multiple column variations
|
||||
- ✅ Auto-responsive grid
|
||||
|
||||
## Integration Features
|
||||
|
||||
### Data Layer Integration
|
||||
- ✅ Works with `lib/data.ts` types (Post, Product, ProductCategory)
|
||||
- ✅ Uses data access functions
|
||||
- ✅ Supports media resolution
|
||||
- ✅ Translation support
|
||||
|
||||
### UI System Integration
|
||||
- ✅ Uses existing UI components (Card, Button, Badge)
|
||||
- ✅ Consistent with design system
|
||||
- ✅ Follows component patterns
|
||||
- ✅ Reuses utility functions
|
||||
|
||||
### Next.js Integration
|
||||
- ✅ Client components with 'use client'
|
||||
- ✅ Link component for navigation
|
||||
- ✅ Image optimization ready
|
||||
- ✅ TypeScript support
|
||||
- ✅ Proper prop typing
|
||||
|
||||
## Design System Compliance
|
||||
|
||||
### Colors
|
||||
- Primary, Secondary, Success, Warning, Error, Info variants
|
||||
- Neutral badges for categories
|
||||
- Hover states with dark variants
|
||||
|
||||
### Typography
|
||||
- Consistent font sizes across sizes
|
||||
- Proper hierarchy (title, description, metadata)
|
||||
- Readable line heights
|
||||
|
||||
### Spacing
|
||||
- Consistent padding (sm, md, lg)
|
||||
- Gap spacing (xs, sm, md, lg, xl)
|
||||
- Margin patterns
|
||||
|
||||
### Responsiveness
|
||||
- Mobile-first design
|
||||
- Breakpoints: sm, md, lg, xl
|
||||
- Flexible layouts
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Simple Blog Grid
|
||||
```tsx
|
||||
import { BlogCard, CardGrid3 } from '@/components/cards';
|
||||
|
||||
<CardGrid3>
|
||||
{posts.map(post => (
|
||||
<BlogCard key={post.id} post={post} />
|
||||
))}
|
||||
</CardGrid3>
|
||||
```
|
||||
|
||||
### Product Catalog
|
||||
```tsx
|
||||
import { ProductCard, CardGrid4 } from '@/components/cards';
|
||||
|
||||
<CardGrid4>
|
||||
{products.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
))}
|
||||
</CardGrid4>
|
||||
```
|
||||
|
||||
### Category Navigation
|
||||
```tsx
|
||||
import { CategoryCard, CardGridAuto } from '@/components/cards';
|
||||
|
||||
<CardGridAuto>
|
||||
{categories.map(category => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
useIcon={true}
|
||||
/>
|
||||
))}
|
||||
</CardGridAuto>
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
1. **Consistency**: All cards follow the same patterns
|
||||
2. **Flexibility**: Multiple sizes, layouts, and variants
|
||||
3. **Type Safety**: Full TypeScript support
|
||||
4. **Performance**: Optimized with proper loading states
|
||||
5. **Accessibility**: Semantic HTML and ARIA support
|
||||
6. **Maintainability**: Clean, documented code
|
||||
7. **Extensibility**: Easy to add new variants
|
||||
8. **Internationalization**: Built-in locale support
|
||||
|
||||
## Migration Path
|
||||
|
||||
### From ProductList
|
||||
```tsx
|
||||
// Old
|
||||
<ProductList products={products} locale="de" />
|
||||
|
||||
// New
|
||||
<CardGrid3>
|
||||
{products.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
/>
|
||||
))}
|
||||
</CardGrid3>
|
||||
```
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Visual Testing**: Check all sizes and layouts
|
||||
2. **Responsive Testing**: Test on mobile, tablet, desktop
|
||||
3. **Data Testing**: Test with various data states
|
||||
4. **Loading States**: Verify skeleton loaders
|
||||
5. **Empty States**: Test with empty arrays
|
||||
6. **Link Navigation**: Verify href routing
|
||||
7. **Interactive Elements**: Test buttons and hover effects
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add animation variants
|
||||
- [ ] Support for video backgrounds
|
||||
- [ ] Lazy loading with Intersection Observer
|
||||
- [ ] Progressive image loading
|
||||
- [ ] Custom color schemes
|
||||
- [ ] Drag and drop support
|
||||
- [ ] Touch gestures for mobile
|
||||
- [ ] A/B testing variants
|
||||
|
||||
## Documentation
|
||||
|
||||
- ✅ Comprehensive README.md
|
||||
- ✅ Inline code comments
|
||||
- ✅ TypeScript JSDoc comments
|
||||
- ✅ Usage examples in CardsExample.tsx
|
||||
- ✅ Props documentation
|
||||
- ✅ Best practices guide
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
- ✅ TypeScript compilation
|
||||
- ✅ Consistent naming conventions
|
||||
- ✅ Proper error handling
|
||||
- ✅ Performance considerations
|
||||
- ✅ Accessibility compliance
|
||||
- ✅ Design system alignment
|
||||
|
||||
## Conclusion
|
||||
|
||||
The card components are production-ready and provide a solid foundation for displaying WordPress content in a consistent, flexible, and performant way. They integrate seamlessly with the existing codebase and follow all established patterns and best practices.
|
||||
@@ -1,192 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LoadingSkeleton } from '@/components/ui';
|
||||
|
||||
// CardGrid column options
|
||||
export type GridColumns = 1 | 2 | 3 | 4;
|
||||
|
||||
// CardGrid gap options
|
||||
export type GridGap = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
// CardGrid props interface
|
||||
export interface CardGridProps {
|
||||
/** Card items to render */
|
||||
items?: ReactNode[];
|
||||
/** Number of columns */
|
||||
columns?: GridColumns;
|
||||
/** Gap spacing */
|
||||
gap?: GridGap;
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Empty state message */
|
||||
emptyMessage?: string;
|
||||
/** Empty state component */
|
||||
emptyComponent?: ReactNode;
|
||||
/** Loading skeleton count */
|
||||
skeletonCount?: number;
|
||||
/** Additional classes */
|
||||
className?: string;
|
||||
/** Children (alternative to items) */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Helper function to get gap classes
|
||||
const getGapClasses = (gap: GridGap): string => {
|
||||
const gapMap = {
|
||||
xs: 'gap-2',
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
xl: 'gap-12',
|
||||
};
|
||||
return gapMap[gap];
|
||||
};
|
||||
|
||||
// Helper function to get column classes
|
||||
const getColumnClasses = (columns: GridColumns): string => {
|
||||
const columnMap = {
|
||||
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-3 xl:grid-cols-4',
|
||||
};
|
||||
return columnMap[columns];
|
||||
};
|
||||
|
||||
// Skeleton loader component
|
||||
const GridSkeleton = ({
|
||||
count,
|
||||
columns,
|
||||
gap
|
||||
}: {
|
||||
count: number;
|
||||
columns: GridColumns;
|
||||
gap: GridGap;
|
||||
}) => {
|
||||
const gapClasses = getGapClasses(gap);
|
||||
const columnClasses = getColumnClasses(columns);
|
||||
|
||||
return (
|
||||
<div className={cn('grid', columnClasses, gapClasses)}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="bg-gray-200 rounded-lg h-64" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Empty state component
|
||||
const EmptyState = ({
|
||||
message,
|
||||
customComponent
|
||||
}: {
|
||||
message?: string;
|
||||
customComponent?: ReactNode;
|
||||
}) => {
|
||||
if (customComponent) {
|
||||
return <div className="text-center py-12">{customComponent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-lg mb-2">
|
||||
{message || 'No items to display'}
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm">
|
||||
{message ? '' : 'Try adjusting your filters or check back later'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardGrid: React.FC<CardGridProps> = ({
|
||||
items,
|
||||
columns = 3,
|
||||
gap = 'md',
|
||||
loading = false,
|
||||
emptyMessage,
|
||||
emptyComponent,
|
||||
skeletonCount = 6,
|
||||
className = '',
|
||||
children,
|
||||
}) => {
|
||||
// Use children if provided, otherwise use items
|
||||
const content = children || items;
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<GridSkeleton
|
||||
count={skeletonCount}
|
||||
columns={columns}
|
||||
gap={gap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!content || (Array.isArray(content) && content.length === 0)) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={emptyMessage}
|
||||
customComponent={emptyComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render grid
|
||||
const gapClasses = getGapClasses(gap);
|
||||
const columnClasses = getColumnClasses(columns);
|
||||
|
||||
return (
|
||||
<div className={cn('grid', columnClasses, gapClasses, className)}>
|
||||
{Array.isArray(content)
|
||||
? content.map((item, index) => (
|
||||
<div key={index} className="contents">
|
||||
{item}
|
||||
</div>
|
||||
))
|
||||
: content
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Grid variations
|
||||
export const CardGrid2: React.FC<CardGridProps> = (props) => (
|
||||
<CardGrid {...props} columns={2} />
|
||||
);
|
||||
|
||||
export const CardGrid3: React.FC<CardGridProps> = (props) => (
|
||||
<CardGrid {...props} columns={3} />
|
||||
);
|
||||
|
||||
export const CardGrid4: React.FC<CardGridProps> = (props) => (
|
||||
<CardGrid {...props} columns={4} />
|
||||
);
|
||||
|
||||
// Responsive grid with auto columns
|
||||
export const CardGridAuto: React.FC<CardGridProps> = ({
|
||||
gap = 'md',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const gapClasses = getGapClasses(gap);
|
||||
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', gapClasses, className)}>
|
||||
{props.items && Array.isArray(props.items)
|
||||
? props.items.map((item, index) => (
|
||||
<div key={index} className="contents">
|
||||
{item}
|
||||
</div>
|
||||
))
|
||||
: props.children
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,485 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BlogCard,
|
||||
ProductCard,
|
||||
CategoryCard,
|
||||
CardGrid,
|
||||
CardGrid2,
|
||||
CardGrid3,
|
||||
CardGrid4,
|
||||
CardGridAuto
|
||||
} from './index';
|
||||
import { Container, Button } from '@/components/ui';
|
||||
import { Post, Product, ProductCategory } from '@/lib/data';
|
||||
|
||||
/**
|
||||
* CardsExample - Comprehensive example showing all card variations
|
||||
* This component demonstrates how to use the card components with real data
|
||||
*/
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockPosts: Post[] = [
|
||||
{
|
||||
id: 1,
|
||||
translationKey: 'post-1',
|
||||
locale: 'de',
|
||||
slug: 'weltweite-lieferketten',
|
||||
path: '/de/blog/weltweite-lieferketten',
|
||||
title: 'Weltweite Lieferketten: Herausforderungen und Lösungen',
|
||||
titleHtml: '<strong>Weltweite Lieferketten</strong>: Herausforderungen und Lösungen',
|
||||
contentHtml: '<p>Die globalen Lieferketten stehen vor unprecedented Herausforderungen...</p>',
|
||||
excerptHtml: 'Erfahren Sie mehr über die aktuellen Herausforderungen in globalen Lieferketten und wie wir Lösungen entwickeln.',
|
||||
featuredImage: 10988,
|
||||
datePublished: '2024-12-15',
|
||||
updatedAt: '2024-12-15',
|
||||
translation: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
translationKey: 'post-2',
|
||||
locale: 'de',
|
||||
slug: 'nachhaltige-energie',
|
||||
path: '/de/blog/nachhaltige-energie',
|
||||
title: 'Nachhaltige Energie: Die Zukunft der Stromversorgung',
|
||||
titleHtml: '<strong>Nachhaltige Energie</strong>: Die Zukunft der Stromversorgung',
|
||||
contentHtml: '<p>Die Energiewende erfordert innovative Kabel- und Leitungslösungen...</p>',
|
||||
excerptHtml: 'Entdecken Sie, wie moderne Kabeltechnologie zur Energiewende beiträgt.',
|
||||
featuredImage: 20928,
|
||||
datePublished: '2024-12-10',
|
||||
updatedAt: '2024-12-10',
|
||||
translation: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockProducts: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
translationKey: 'product-1',
|
||||
locale: 'de',
|
||||
slug: 'n2xsfl2y-12-20kv',
|
||||
path: '/de/produkte/n2xsfl2y-12-20kv',
|
||||
name: 'N2XSFL2Y 12/20kV',
|
||||
shortDescriptionHtml: 'Mittelspannungskabel mit LSA-Plus Verbindungssystem',
|
||||
descriptionHtml: '<p>Das N2XSFL2Y Kabel ist für den Einsatz in Mittelspannungsnetzen optimiert...</p>',
|
||||
images: [
|
||||
'/media/media-1766870855811-N2XSFL2Y-3-scaled.webp',
|
||||
'/media/media-1766870855815-N2XSFL2Y-2-scaled.webp',
|
||||
],
|
||||
featuredImage: '/media/media-1766870855811-N2XSFL2Y-3-scaled.webp',
|
||||
sku: 'N2XSFL2Y-12-20KV',
|
||||
regularPrice: '125.50',
|
||||
salePrice: '',
|
||||
currency: 'EUR',
|
||||
stockStatus: 'instock',
|
||||
categories: [{ id: 1, name: 'Mittelspannung', slug: 'mittelspannung' }],
|
||||
attributes: [],
|
||||
variations: [],
|
||||
updatedAt: '2024-12-20',
|
||||
translation: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
translationKey: 'product-2',
|
||||
locale: 'de',
|
||||
slug: 'na2xsf2x-0-6-1kv',
|
||||
path: '/de/produkte/na2xsf2x-0-6-1kv',
|
||||
name: 'NA2XSF2X 0,6/1kV',
|
||||
shortDescriptionHtml: 'Niederspannungskabel für industrielle Anwendungen',
|
||||
descriptionHtml: '<p>Robustes Niederspannungskabel für den industriellen Einsatz...</p>',
|
||||
images: [
|
||||
'/media/47052-NA2XSF2X_3x1x300_RM-25_12-20kV-3.webp',
|
||||
],
|
||||
featuredImage: '/media/47052-NA2XSF2X_3x1x300_RM-25_12-20kV-3.webp',
|
||||
sku: 'NA2XSF2X-0-6-1KV',
|
||||
regularPrice: '45.00',
|
||||
salePrice: '38.50',
|
||||
currency: 'EUR',
|
||||
stockStatus: 'instock',
|
||||
categories: [{ id: 2, name: 'Niederspannung', slug: 'niederspannung' }],
|
||||
attributes: [],
|
||||
variations: [],
|
||||
updatedAt: '2024-12-18',
|
||||
translation: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
translationKey: 'product-3',
|
||||
locale: 'de',
|
||||
slug: 'h1z2z2-k',
|
||||
path: '/de/produkte/h1z2z2-k',
|
||||
name: 'H1Z2Z2-K',
|
||||
shortDescriptionHtml: 'Solarleiterkabel für Photovoltaikanlagen',
|
||||
descriptionHtml: '<p>Spezielles Solarleiterkabel für den Einsatz in PV-Anlagen...</p>',
|
||||
images: [
|
||||
'/media/media-1766870855813-H1Z2Z2-K-scaled.webp',
|
||||
],
|
||||
featuredImage: '/media/media-1766870855813-H1Z2Z2-K-scaled.webp',
|
||||
sku: 'H1Z2Z2-K',
|
||||
regularPrice: '28.90',
|
||||
salePrice: '',
|
||||
currency: 'EUR',
|
||||
stockStatus: 'onbackorder',
|
||||
categories: [{ id: 3, name: 'Solar', slug: 'solar' }],
|
||||
attributes: [],
|
||||
variations: [],
|
||||
updatedAt: '2024-12-22',
|
||||
translation: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockCategories: ProductCategory[] = [
|
||||
{
|
||||
id: 1,
|
||||
translationKey: 'cat-1',
|
||||
locale: 'de',
|
||||
slug: 'mittelspannung',
|
||||
name: 'Mittelspannung',
|
||||
path: '/de/produkt-kategorie/mittelspannung',
|
||||
description: 'Kabel und Leitungen für Mittelspannungsnetze bis 36kV',
|
||||
count: 12,
|
||||
translation: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
translationKey: 'cat-2',
|
||||
locale: 'de',
|
||||
slug: 'niederspannung',
|
||||
name: 'Niederspannung',
|
||||
path: '/de/produkt-kategorie/niederspannung',
|
||||
description: 'Kabel für Niederspannungsanwendungen bis 1kV',
|
||||
count: 25,
|
||||
translation: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
translationKey: 'cat-3',
|
||||
locale: 'de',
|
||||
slug: 'solar',
|
||||
name: 'Solar',
|
||||
path: '/de/produkt-kategorie/solar',
|
||||
description: 'Spezielle Solarleiterkabel und Zubehör',
|
||||
count: 8,
|
||||
translation: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
translationKey: 'cat-4',
|
||||
locale: 'de',
|
||||
slug: 'industrie',
|
||||
name: 'Industrie',
|
||||
path: '/de/produkt-kategorie/industrie',
|
||||
description: 'Industrielle Kabel für anspruchsvolle Umgebungen',
|
||||
count: 18,
|
||||
translation: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const CardsExample: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Simulate loading
|
||||
const simulateLoading = () => {
|
||||
setLoading(true);
|
||||
setTimeout(() => setLoading(false), 2000);
|
||||
};
|
||||
|
||||
// Handle add to cart
|
||||
const handleAddToCart = (product: Product) => {
|
||||
console.log('Add to cart:', product.name);
|
||||
alert(`"${product.name}" wurde zum Warenkorb hinzugefügt!`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="py-8 space-y-12">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900">
|
||||
Card Components Showcase
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
Comprehensive examples of all card variations for WordPress content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Blog Cards Section */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Blog Cards</h2>
|
||||
<Button onClick={simulateLoading} variant="outline">
|
||||
Simulate Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Vertical Layout</h3>
|
||||
<CardGrid2>
|
||||
{mockPosts.map(post => (
|
||||
<BlogCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
size="md"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
readMoreText="Weiterlesen"
|
||||
/>
|
||||
))}
|
||||
</CardGrid2>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Horizontal Layout</h3>
|
||||
<div className="space-y-4">
|
||||
{mockPosts.map(post => (
|
||||
<BlogCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
layout="horizontal"
|
||||
size="md"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
readMoreText="Weiterlesen"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Different Sizes</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<BlogCard
|
||||
post={mockPosts[0]}
|
||||
size="sm"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
/>
|
||||
<BlogCard
|
||||
post={mockPosts[0]}
|
||||
size="md"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
/>
|
||||
<BlogCard
|
||||
post={mockPosts[0]}
|
||||
size="lg"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product Cards Section */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Product Cards</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Grid Layout</h3>
|
||||
<CardGrid3>
|
||||
{mockProducts.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
size="md"
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
showSku={true}
|
||||
showCategories={true}
|
||||
showAddToCart={true}
|
||||
showViewDetails={false}
|
||||
onAddToCart={handleAddToCart}
|
||||
locale="de"
|
||||
/>
|
||||
))}
|
||||
</CardGrid3>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Horizontal Layout</h3>
|
||||
<div className="space-y-4">
|
||||
{mockProducts.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
layout="horizontal"
|
||||
size="md"
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
showSku={true}
|
||||
showCategories={true}
|
||||
showAddToCart={true}
|
||||
onAddToCart={handleAddToCart}
|
||||
locale="de"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Image Hover Swap</h3>
|
||||
<CardGrid4>
|
||||
{mockProducts.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
size="sm"
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
enableImageSwap={true}
|
||||
onAddToCart={handleAddToCart}
|
||||
locale="de"
|
||||
/>
|
||||
))}
|
||||
</CardGrid4>
|
||||
</section>
|
||||
|
||||
{/* Category Cards Section */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Category Cards</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Standard Layout</h3>
|
||||
<CardGrid4>
|
||||
{mockCategories.map(category => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
size="md"
|
||||
showCount={true}
|
||||
showDescription={true}
|
||||
locale="de"
|
||||
/>
|
||||
))}
|
||||
</CardGrid4>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Icon-Based Layout</h3>
|
||||
<CardGridAuto>
|
||||
{mockCategories.map(category => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
size="sm"
|
||||
useIcon={true}
|
||||
showCount={true}
|
||||
showDescription={false}
|
||||
locale="de"
|
||||
/>
|
||||
))}
|
||||
</CardGridAuto>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Horizontal Layout</h3>
|
||||
<div className="space-y-3">
|
||||
{mockCategories.map(category => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
layout="horizontal"
|
||||
size="md"
|
||||
showCount={true}
|
||||
showDescription={true}
|
||||
locale="de"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Loading States Section */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Loading States</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">CardGrid Loading</h3>
|
||||
{loading && (
|
||||
<CardGrid3 loading={true} skeletonCount={6} />
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-700">Empty States</h3>
|
||||
<CardGrid3 items={[]} emptyMessage="Keine Produkte gefunden" />
|
||||
</section>
|
||||
|
||||
{/* Mixed Content Section */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Mixed Content Grid</h2>
|
||||
<CardGridAuto>
|
||||
<BlogCard post={mockPosts[0]} size="sm" showDate={true} />
|
||||
<ProductCard
|
||||
product={mockProducts[0]}
|
||||
size="sm"
|
||||
showPrice={true}
|
||||
showStock={false}
|
||||
/>
|
||||
<CategoryCard
|
||||
category={mockCategories[0]}
|
||||
size="sm"
|
||||
useIcon={true}
|
||||
showCount={false}
|
||||
/>
|
||||
<BlogCard post={mockPosts[1]} size="sm" showDate={true} />
|
||||
<ProductCard
|
||||
product={mockProducts[1]}
|
||||
size="sm"
|
||||
showPrice={true}
|
||||
showStock={false}
|
||||
/>
|
||||
<CategoryCard
|
||||
category={mockCategories[1]}
|
||||
size="sm"
|
||||
useIcon={true}
|
||||
showCount={false}
|
||||
/>
|
||||
</CardGridAuto>
|
||||
</section>
|
||||
|
||||
{/* Usage Examples */}
|
||||
<section className="space-y-6 bg-gray-50 p-6 rounded-lg">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Usage Examples</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Basic Blog Card</h3>
|
||||
<pre className="bg-gray-800 text-white p-4 rounded text-sm overflow-x-auto">
|
||||
{`<BlogCard
|
||||
post={post}
|
||||
size="md"
|
||||
layout="vertical"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
readMoreText="Weiterlesen"
|
||||
/>`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Product Card with Cart</h3>
|
||||
<pre className="bg-gray-800 text-white p-4 rounded text-sm overflow-x-auto">
|
||||
{`<ProductCard
|
||||
product={product}
|
||||
size="md"
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
showAddToCart={true}
|
||||
onAddToCart={(p) => console.log('Added:', p.name)}
|
||||
/>`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Category Card Grid</h3>
|
||||
<pre className="bg-gray-800 text-white p-4 rounded text-sm overflow-x-auto">
|
||||
{`<CardGrid4>
|
||||
{categories.map(cat => (
|
||||
<CategoryCard
|
||||
key={cat.id}
|
||||
category={cat}
|
||||
size="md"
|
||||
showCount={true}
|
||||
/>
|
||||
))}
|
||||
</CardGrid4>`}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Practices */}
|
||||
<section className="space-y-6 bg-blue-50 p-6 rounded-lg border border-blue-200">
|
||||
<h2 className="text-2xl font-bold text-blue-900">Best Practices</h2>
|
||||
<ul className="space-y-2 text-blue-800">
|
||||
<li>✓ Use CardGrid components for consistent spacing and responsive layouts</li>
|
||||
<li>✓ Always provide alt text for images</li>
|
||||
<li>✓ Use appropriate sizes for different contexts (sm for lists, md for grids, lg for featured)</li>
|
||||
<li>✓ Enable hover effects for better user experience</li>
|
||||
<li>✓ Show loading states when fetching data</li>
|
||||
<li>✓ Handle empty states gracefully</li>
|
||||
<li>✓ Use the locale prop for internationalization</li>
|
||||
<li>✓ Integrate with your data layer using the types from lib/data.ts</li>
|
||||
</ul>
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardsExample;
|
||||
@@ -1,194 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { BaseCard, BaseCardProps, CardSize, CardLayout } from './BaseCard';
|
||||
import { Badge, BadgeGroup } from '@/components/ui';
|
||||
import { ProductCategory } from '@/lib/data';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// CategoryCard specific props
|
||||
export interface CategoryCardProps extends Omit<BaseCardProps, 'title' | 'description' | 'image' | 'footer'> {
|
||||
/** Category data from WordPress */
|
||||
category: ProductCategory;
|
||||
/** Display product count */
|
||||
showCount?: boolean;
|
||||
/** Display description */
|
||||
showDescription?: boolean;
|
||||
/** Display as icon instead of image */
|
||||
useIcon?: boolean;
|
||||
/** Icon component (if useIcon is true) */
|
||||
icon?: React.ReactNode;
|
||||
/** Category type */
|
||||
categoryType?: 'product' | 'blog';
|
||||
/** Locale for formatting */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// Helper to get category image
|
||||
const getCategoryImage = (category: ProductCategory): string | undefined => {
|
||||
// In a real implementation, this would use getMediaById
|
||||
// For now, return a placeholder based on category ID
|
||||
if (category.id % 3 === 0) {
|
||||
return '/media/6517-medium-voltage-category.webp';
|
||||
} else if (category.id % 3 === 1) {
|
||||
return '/media/6521-low-voltage-category.webp';
|
||||
} else {
|
||||
return '/media/10863-klz-directory-2-scaled.webp';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get icon for category
|
||||
const getCategoryIcon = (category: ProductCategory): React.ReactNode => {
|
||||
const iconMap = {
|
||||
1: '🔌', // Low Voltage
|
||||
2: '⚡', // Medium Voltage
|
||||
3: '🏭', // Industrial
|
||||
4: '🏗️', // Construction
|
||||
5: '🏠', // Residential
|
||||
};
|
||||
|
||||
return iconMap[category.id as keyof typeof iconMap] || '📁';
|
||||
};
|
||||
|
||||
// Helper to get category color variant
|
||||
const getCategoryVariant = (category: ProductCategory): 'primary' | 'secondary' | 'success' | 'info' => {
|
||||
const variants = ['primary', 'secondary', 'success', 'info'] as const;
|
||||
return variants[category.id % variants.length];
|
||||
};
|
||||
|
||||
export const CategoryCard: React.FC<CategoryCardProps> = ({
|
||||
category,
|
||||
size = 'md',
|
||||
layout = 'vertical',
|
||||
showCount = true,
|
||||
showDescription = true,
|
||||
useIcon = false,
|
||||
icon,
|
||||
categoryType = 'product',
|
||||
locale = 'de',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
// Get category data
|
||||
const title = category.name;
|
||||
const description = showDescription && category.description ?
|
||||
category.description.replace(/<[^>]*>/g, '').substring(0, 100) + (category.description.length > 100 ? '...' : '') :
|
||||
'';
|
||||
const count = showCount ? category.count : 0;
|
||||
const image = useIcon ? undefined : getCategoryImage(category);
|
||||
const categoryIcon = icon || getCategoryIcon(category);
|
||||
|
||||
// Build badge with count
|
||||
const badge = showCount && count > 0 ? (
|
||||
<Badge variant="neutral" size={size === 'sm' ? 'sm' : 'md'}>
|
||||
{count} {locale === 'de' ? 'Produkte' : 'Products'}
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
// Build header with icon
|
||||
const header = useIcon ? (
|
||||
<span className="text-3xl" role="img" aria-label="Category icon">
|
||||
{categoryIcon}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// Build footer with link text
|
||||
const footer = (
|
||||
<span className={cn(
|
||||
'font-medium transition-colors',
|
||||
getCategoryVariant(category) === 'primary' && 'text-primary hover:text-primary-dark',
|
||||
getCategoryVariant(category) === 'secondary' && 'text-secondary hover:text-secondary-dark',
|
||||
getCategoryVariant(category) === 'success' && 'text-success hover:text-success-dark',
|
||||
getCategoryVariant(category) === 'info' && 'text-info hover:text-info-dark'
|
||||
)}>
|
||||
{locale === 'de' ? 'Anzeigen' : 'View'} →
|
||||
</span>
|
||||
);
|
||||
|
||||
// Build description with count
|
||||
const descriptionContent = (
|
||||
<div>
|
||||
{description && <div className="text-gray-600 mb-2">{description}</div>}
|
||||
{showCount && count > 0 && (
|
||||
<div className="text-sm font-semibold text-gray-700">
|
||||
{count} {locale === 'de' ? 'Produkte' : 'Products'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Build icon content for vertical layout
|
||||
const iconContent = useIcon ? (
|
||||
<div className={cn(
|
||||
'flex items-center justify-center rounded-lg',
|
||||
getCategoryVariant(category) === 'primary' && 'bg-primary/10 text-primary',
|
||||
getCategoryVariant(category) === 'secondary' && 'bg-secondary/10 text-secondary',
|
||||
getCategoryVariant(category) === 'success' && 'bg-success/10 text-success',
|
||||
getCategoryVariant(category) === 'info' && 'bg-info/10 text-info',
|
||||
size === 'sm' && 'w-12 h-12 text-xl',
|
||||
size === 'md' && 'w-16 h-16 text-2xl',
|
||||
size === 'lg' && 'w-20 h-20 text-3xl'
|
||||
)}>
|
||||
{categoryIcon}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Override title to include icon for horizontal layout
|
||||
const titleContent = useIcon && layout === 'horizontal' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{iconContent}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
) : title;
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={titleContent}
|
||||
description={descriptionContent}
|
||||
image={useIcon ? undefined : image}
|
||||
imageAlt={title}
|
||||
size={size}
|
||||
layout={layout}
|
||||
href={category.path}
|
||||
badge={badge}
|
||||
header={useIcon && layout === 'vertical' ? null : header}
|
||||
footer={footer}
|
||||
hoverable={true}
|
||||
variant="elevated"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{/* For vertical layout with icon, add icon above title */}
|
||||
{useIcon && layout === 'vertical' && (
|
||||
<div className="mb-2">
|
||||
{iconContent}
|
||||
</div>
|
||||
)}
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
// CategoryCard variations
|
||||
export const CategoryCardVertical: React.FC<CategoryCardProps> = (props) => (
|
||||
<CategoryCard {...props} layout="vertical" />
|
||||
);
|
||||
|
||||
export const CategoryCardHorizontal: React.FC<CategoryCardProps> = (props) => (
|
||||
<CategoryCard {...props} layout="horizontal" />
|
||||
);
|
||||
|
||||
export const CategoryCardSmall: React.FC<CategoryCardProps> = (props) => (
|
||||
<CategoryCard {...props} size="sm" />
|
||||
);
|
||||
|
||||
export const CategoryCardLarge: React.FC<CategoryCardProps> = (props) => (
|
||||
<CategoryCard {...props} size="lg" />
|
||||
);
|
||||
|
||||
// Icon-only category card
|
||||
export const CategoryCardIcon: React.FC<CategoryCardProps> = (props) => (
|
||||
<CategoryCard {...props} useIcon={true} showDescription={false} />
|
||||
);
|
||||
|
||||
// Export types
|
||||
export type { CardSize, CardLayout };
|
||||
@@ -1,251 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { BaseCard, BaseCardProps, CardSize, CardLayout } from './BaseCard';
|
||||
import { Badge, BadgeGroup, Button } from '@/components/ui';
|
||||
import { Product } from '@/lib/data';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ProductCard specific props
|
||||
export interface ProductCardProps extends Omit<BaseCardProps, 'title' | 'description' | 'image' | 'footer'> {
|
||||
/** Product data from WordPress */
|
||||
product: Product;
|
||||
/** Display price */
|
||||
showPrice?: boolean;
|
||||
/** Display stock status */
|
||||
showStock?: boolean;
|
||||
/** Display SKU */
|
||||
showSku?: boolean;
|
||||
/** Display categories */
|
||||
showCategories?: boolean;
|
||||
/** Display add to cart button */
|
||||
showAddToCart?: boolean;
|
||||
/** Display view details button */
|
||||
showViewDetails?: boolean;
|
||||
/** Enable image hover swap */
|
||||
enableImageSwap?: boolean;
|
||||
/** Locale for formatting */
|
||||
locale?: string;
|
||||
/** Add to cart handler */
|
||||
onAddToCart?: (product: Product) => void;
|
||||
}
|
||||
|
||||
// Helper to get price display
|
||||
const getPriceDisplay = (product: Product) => {
|
||||
const { regularPrice, salePrice, currency } = product;
|
||||
|
||||
if (salePrice && salePrice !== regularPrice) {
|
||||
return {
|
||||
current: salePrice,
|
||||
original: regularPrice,
|
||||
isOnSale: true,
|
||||
formatted: `${salePrice} ${currency} ~~${regularPrice} ${currency}~~`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
current: regularPrice,
|
||||
original: null,
|
||||
isOnSale: false,
|
||||
formatted: `${regularPrice} ${currency}`
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to get stock status
|
||||
const getStockStatus = (stockStatus: string, locale: string = 'de') => {
|
||||
const statusMap = {
|
||||
instock: {
|
||||
text: locale === 'de' ? 'Auf Lager' : 'In Stock',
|
||||
variant: 'success' as const,
|
||||
},
|
||||
outofstock: {
|
||||
text: locale === 'de' ? 'Nicht auf Lager' : 'Out of Stock',
|
||||
variant: 'error' as const,
|
||||
},
|
||||
onbackorder: {
|
||||
text: locale === 'de' ? 'Nachbestellung' : 'On Backorder',
|
||||
variant: 'warning' as const,
|
||||
},
|
||||
};
|
||||
|
||||
return statusMap[stockStatus as keyof typeof statusMap] || statusMap.outofstock;
|
||||
};
|
||||
|
||||
// Helper to get product image
|
||||
const getProductImage = (product: Product, index: number = 0): string | undefined => {
|
||||
if (product.images && product.images.length > index) {
|
||||
return product.images[index];
|
||||
}
|
||||
if (product.featuredImage) {
|
||||
return product.featuredImage;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
product,
|
||||
size = 'md',
|
||||
layout = 'vertical',
|
||||
showPrice = true,
|
||||
showStock = true,
|
||||
showSku = false,
|
||||
showCategories = true,
|
||||
showAddToCart = true,
|
||||
showViewDetails = false,
|
||||
enableImageSwap = true,
|
||||
locale = 'de',
|
||||
onAddToCart,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
|
||||
// Get product data
|
||||
const title = product.name;
|
||||
const description = product.shortDescriptionHtml ?
|
||||
product.shortDescriptionHtml.replace(/<[^>]*>/g, '').substring(0, 100) + '...' :
|
||||
'';
|
||||
const primaryImage = getProductImage(product, currentImageIndex);
|
||||
const priceInfo = showPrice ? getPriceDisplay(product) : null;
|
||||
const stockInfo = showStock ? getStockStatus(product.stockStatus, locale) : null;
|
||||
const categories = showCategories ? product.categories.map(c => c.name) : [];
|
||||
const sku = showSku ? product.sku : null;
|
||||
|
||||
// Build badge component for categories and stock
|
||||
const badge = (
|
||||
<BadgeGroup gap="xs">
|
||||
{showStock && stockInfo && (
|
||||
<Badge variant={stockInfo.variant} size={size === 'sm' ? 'sm' : 'md'}>
|
||||
{stockInfo.text}
|
||||
</Badge>
|
||||
)}
|
||||
{categories.map((category, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="neutral"
|
||||
size={size === 'sm' ? 'sm' : 'md'}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</BadgeGroup>
|
||||
);
|
||||
|
||||
// Build header with SKU
|
||||
const header = sku ? (
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
SKU: {sku}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// Build price display
|
||||
const priceDisplay = priceInfo ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'font-bold',
|
||||
priceInfo.isOnSale ? 'text-red-600' : 'text-gray-900',
|
||||
size === 'sm' && 'text-sm',
|
||||
size === 'md' && 'text-base',
|
||||
size === 'lg' && 'text-lg'
|
||||
)}>
|
||||
{priceInfo.current}
|
||||
</span>
|
||||
{priceInfo.isOnSale && (
|
||||
<span className="text-sm text-gray-400 line-through">
|
||||
{priceInfo.original}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Build footer with buttons
|
||||
const footer = (
|
||||
<div className="flex gap-2 w-full">
|
||||
{showAddToCart && (
|
||||
<Button
|
||||
size={size === 'sm' ? 'sm' : 'md'}
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (onAddToCart) onAddToCart(product);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
{locale === 'de' ? 'In den Warenkorb' : 'Add to Cart'}
|
||||
</Button>
|
||||
)}
|
||||
{showViewDetails && (
|
||||
<Button
|
||||
size={size === 'sm' ? 'sm' : 'md'}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{locale === 'de' ? 'Details' : 'Details'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Build description with price
|
||||
const descriptionContent = (
|
||||
<div>
|
||||
{description && <div className="text-gray-600 mb-2">{description}</div>}
|
||||
{priceDisplay}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Handle image hover for swap
|
||||
const handleMouseEnter = () => {
|
||||
if (enableImageSwap && product.images && product.images.length > 1) {
|
||||
setCurrentImageIndex(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (enableImageSwap) {
|
||||
setCurrentImageIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={title}
|
||||
description={descriptionContent}
|
||||
image={primaryImage}
|
||||
imageAlt={title}
|
||||
size={size}
|
||||
layout={layout}
|
||||
href={product.path}
|
||||
badge={badge}
|
||||
header={header}
|
||||
footer={footer}
|
||||
hoverable={true}
|
||||
variant="elevated"
|
||||
className={className}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ProductCard variations
|
||||
export const ProductCardVertical: React.FC<ProductCardProps> = (props) => (
|
||||
<ProductCard {...props} layout="vertical" />
|
||||
);
|
||||
|
||||
export const ProductCardHorizontal: React.FC<ProductCardProps> = (props) => (
|
||||
<ProductCard {...props} layout="horizontal" />
|
||||
);
|
||||
|
||||
export const ProductCardSmall: React.FC<ProductCardProps> = (props) => (
|
||||
<ProductCard {...props} size="sm" />
|
||||
);
|
||||
|
||||
export const ProductCardLarge: React.FC<ProductCardProps> = (props) => (
|
||||
<ProductCard {...props} size="lg" />
|
||||
);
|
||||
|
||||
// Export types
|
||||
export type { CardSize, CardLayout };
|
||||
@@ -1,319 +0,0 @@
|
||||
# Card Components
|
||||
|
||||
A comprehensive collection of specialized card components for displaying different content types from WordPress. These components provide consistent layouts across the site and replace the previous ProductList component.
|
||||
|
||||
## Overview
|
||||
|
||||
The card components are designed to work seamlessly with WordPress data structures from `lib/data.ts` and provide:
|
||||
|
||||
- **Consistent Design**: Unified styling and layout patterns
|
||||
- **Responsive Design**: Works across all screen sizes
|
||||
- **Internationalization**: Built-in support for multiple locales
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Flexibility**: Multiple sizes and layouts
|
||||
- **Performance**: Optimized with Next.js Image component
|
||||
|
||||
## Component Structure
|
||||
|
||||
```
|
||||
components/cards/
|
||||
├── BaseCard.tsx # Foundation component
|
||||
├── BlogCard.tsx # Blog post cards
|
||||
├── ProductCard.tsx # Product cards
|
||||
├── CategoryCard.tsx # Category cards
|
||||
├── CardGrid.tsx # Grid wrapper
|
||||
├── CardsExample.tsx # Usage examples
|
||||
├── index.ts # Exports
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### BaseCard
|
||||
|
||||
The foundation component that all other cards extend.
|
||||
|
||||
**Props:**
|
||||
- `title`: Card title (ReactNode)
|
||||
- `description`: Card description (ReactNode)
|
||||
- `image`: Image URL (string)
|
||||
- `imageAlt`: Image alt text (string)
|
||||
- `size`: 'sm' | 'md' | 'lg'
|
||||
- `layout`: 'vertical' | 'horizontal'
|
||||
- `href`: Link URL (string)
|
||||
- `badge`: Badge component (ReactNode)
|
||||
- `footer`: Footer content (ReactNode)
|
||||
- `header`: Header content (ReactNode)
|
||||
- `loading`: Loading state (boolean)
|
||||
- `hoverable`: Enable hover effects (boolean)
|
||||
- `variant`: 'elevated' | 'flat' | 'bordered'
|
||||
- `imageHeight`: 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
### BlogCard
|
||||
|
||||
Displays blog post information with featured image, title, excerpt, date, and categories.
|
||||
|
||||
**Props:**
|
||||
- `post`: Post data from WordPress
|
||||
- `showDate`: Display date (boolean)
|
||||
- `showCategories`: Display categories (boolean)
|
||||
- `readMoreText`: Read more link text (string)
|
||||
- `excerptLength`: Excerpt character limit (number)
|
||||
- `locale`: Formatting locale (string)
|
||||
|
||||
**Variations:**
|
||||
- `BlogCardVertical` - Vertical layout
|
||||
- `BlogCardHorizontal` - Horizontal layout
|
||||
- `BlogCardSmall` - Small size
|
||||
- `BlogCardLarge` - Large size
|
||||
|
||||
### ProductCard
|
||||
|
||||
Displays product information with image gallery, price, stock status, and actions.
|
||||
|
||||
**Props:**
|
||||
- `product`: Product data from WordPress
|
||||
- `showPrice`: Display price (boolean)
|
||||
- `showStock`: Display stock status (boolean)
|
||||
- `showSku`: Display SKU (boolean)
|
||||
- `showCategories`: Display categories (boolean)
|
||||
- `showAddToCart`: Show add to cart button (boolean)
|
||||
- `showViewDetails`: Show view details button (boolean)
|
||||
- `enableImageSwap`: Enable image hover swap (boolean)
|
||||
- `locale`: Formatting locale (string)
|
||||
- `onAddToCart`: Add to cart handler function
|
||||
|
||||
**Variations:**
|
||||
- `ProductCardVertical` - Vertical layout
|
||||
- `ProductCardHorizontal` - Horizontal layout
|
||||
- `ProductCardSmall` - Small size
|
||||
- `ProductCardLarge` - Large size
|
||||
|
||||
### CategoryCard
|
||||
|
||||
Displays category information with image/icon, name, description, and product count.
|
||||
|
||||
**Props:**
|
||||
- `category`: Category data from WordPress
|
||||
- `showCount`: Display product count (boolean)
|
||||
- `showDescription`: Display description (boolean)
|
||||
- `useIcon`: Use icon instead of image (boolean)
|
||||
- `icon`: Custom icon component (ReactNode)
|
||||
- `categoryType`: 'product' | 'blog'
|
||||
- `locale`: Formatting locale (string)
|
||||
|
||||
**Variations:**
|
||||
- `CategoryCardVertical` - Vertical layout
|
||||
- `CategoryCardHorizontal` - Horizontal layout
|
||||
- `CategoryCardSmall` - Small size
|
||||
- `CategoryCardLarge` - Large size
|
||||
- `CategoryCardIcon` - Icon-only variant
|
||||
|
||||
### CardGrid
|
||||
|
||||
Responsive grid wrapper for cards with loading and empty states.
|
||||
|
||||
**Props:**
|
||||
- `items`: Array of card components (ReactNode[])
|
||||
- `columns`: 1 | 2 | 3 | 4
|
||||
- `gap`: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
- `loading`: Loading state (boolean)
|
||||
- `emptyMessage`: Empty state message (string)
|
||||
- `emptyComponent`: Custom empty component (ReactNode)
|
||||
- `skeletonCount`: Loading skeleton count (number)
|
||||
- `className`: Additional classes (string)
|
||||
- `children`: Alternative to items (ReactNode)
|
||||
|
||||
**Variations:**
|
||||
- `CardGrid2` - 2 columns
|
||||
- `CardGrid3` - 3 columns
|
||||
- `CardGrid4` - 4 columns
|
||||
- `CardGridAuto` - Responsive auto columns
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Blog Card
|
||||
|
||||
```tsx
|
||||
import { BlogCard } from '@/components/cards';
|
||||
|
||||
<BlogCard
|
||||
post={post}
|
||||
size="md"
|
||||
layout="vertical"
|
||||
showDate={true}
|
||||
showCategories={true}
|
||||
readMoreText="Weiterlesen"
|
||||
/>
|
||||
```
|
||||
|
||||
### Product Card with Cart
|
||||
|
||||
```tsx
|
||||
import { ProductCard } from '@/components/cards';
|
||||
|
||||
<ProductCard
|
||||
product={product}
|
||||
size="md"
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
showAddToCart={true}
|
||||
onAddToCart={(p) => console.log('Added:', p.name)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Category Grid
|
||||
|
||||
```tsx
|
||||
import { CategoryCard, CardGrid4 } from '@/components/cards';
|
||||
|
||||
<CardGrid4>
|
||||
{categories.map(category => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
size="md"
|
||||
showCount={true}
|
||||
/>
|
||||
))}
|
||||
</CardGrid4>
|
||||
```
|
||||
|
||||
### Mixed Content Grid
|
||||
|
||||
```tsx
|
||||
import { BlogCard, ProductCard, CategoryCard, CardGridAuto } from '@/components/cards';
|
||||
|
||||
<CardGridAuto>
|
||||
<BlogCard post={posts[0]} size="sm" />
|
||||
<ProductCard product={products[0]} size="sm" showPrice={true} />
|
||||
<CategoryCard category={categories[0]} size="sm" useIcon={true} />
|
||||
</CardGridAuto>
|
||||
```
|
||||
|
||||
## Integration with WordPress Data
|
||||
|
||||
All cards are designed to work with the WordPress data structures from `lib/data.ts`:
|
||||
|
||||
```tsx
|
||||
import { getPostsForLocale, getProductsForLocale, getCategoriesForLocale } from '@/lib/data';
|
||||
|
||||
// Get data
|
||||
const posts = getPostsForLocale('de');
|
||||
const products = getProductsForLocale('de');
|
||||
const categories = getCategoriesForLocale('de');
|
||||
|
||||
// Use in components
|
||||
<CardGrid3>
|
||||
{products.map(product => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</CardGrid3>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always provide alt text** for images
|
||||
2. **Use appropriate sizes**: sm for lists, md for grids, lg for featured
|
||||
3. **Enable hover effects** for better UX
|
||||
4. **Show loading states** when fetching data
|
||||
5. **Handle empty states** gracefully
|
||||
6. **Use locale prop** for internationalization
|
||||
7. **Integrate with data layer** using types from `lib/data.ts`
|
||||
8. **Use CardGrid** for consistent spacing and responsive layouts
|
||||
|
||||
## Responsive Design
|
||||
|
||||
All components are fully responsive:
|
||||
|
||||
- **Mobile**: Single column, stacked layout
|
||||
- **Tablet**: 2 columns, optimized spacing
|
||||
- **Desktop**: 3-4 columns, full features
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Semantic HTML structure
|
||||
- Proper ARIA labels
|
||||
- Keyboard navigation support
|
||||
- Screen reader friendly
|
||||
- Focus indicators
|
||||
|
||||
## Performance
|
||||
|
||||
- Optimized with Next.js Image component
|
||||
- Lazy loading for images
|
||||
- Skeleton loading states
|
||||
- Efficient rendering with proper keys
|
||||
- Minimal bundle size
|
||||
|
||||
## Migration from ProductList
|
||||
|
||||
Replace the old ProductList with the new card components:
|
||||
|
||||
**Before:**
|
||||
```tsx
|
||||
import { ProductList } from '@/components/ProductList';
|
||||
|
||||
<ProductList products={products} locale="de" />
|
||||
```
|
||||
|
||||
**After:**
|
||||
```tsx
|
||||
import { ProductCard, CardGrid3 } from '@/components/cards';
|
||||
|
||||
<CardGrid3>
|
||||
{products.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
showPrice={true}
|
||||
showStock={true}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
))}
|
||||
</CardGrid3>
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
All components include full TypeScript definitions:
|
||||
|
||||
```tsx
|
||||
import { BlogCard, BlogCardProps } from '@/components/cards';
|
||||
import { Post } from '@/lib/data';
|
||||
|
||||
const MyComponent: React.FC = () => {
|
||||
const post: Post = { /* ... */ };
|
||||
|
||||
return <BlogCard post={post} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
All components support custom className props for additional styling:
|
||||
|
||||
```tsx
|
||||
<BlogCard
|
||||
post={post}
|
||||
className="custom-card"
|
||||
// ... other props
|
||||
/>
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add animation variants
|
||||
- [ ] Support for video backgrounds
|
||||
- [ ] Lazy loading with Intersection Observer
|
||||
- [ ] Progressive image loading
|
||||
- [ ] Custom color schemes
|
||||
- [ ] Drag and drop support
|
||||
- [ ] Touch gestures for mobile
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues, refer to:
|
||||
- `lib/data.ts` - Data structures
|
||||
- `components/ui/` - UI components
|
||||
- `components/cards/CardsExample.tsx` - Usage examples
|
||||
@@ -1,224 +0,0 @@
|
||||
'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;
|
||||
@@ -1,47 +0,0 @@
|
||||
// Card Components Export
|
||||
// Base Card Component
|
||||
export { BaseCard, type BaseCardProps, type CardSize, type CardLayout } from './BaseCard';
|
||||
|
||||
// Blog Card Components
|
||||
export {
|
||||
BlogCard,
|
||||
BlogCardVertical,
|
||||
BlogCardHorizontal,
|
||||
BlogCardSmall,
|
||||
BlogCardLarge,
|
||||
type BlogCardProps
|
||||
} from './BlogCard';
|
||||
|
||||
// Product Card Components
|
||||
export {
|
||||
ProductCard,
|
||||
ProductCardVertical,
|
||||
ProductCardHorizontal,
|
||||
ProductCardSmall,
|
||||
ProductCardLarge,
|
||||
type ProductCardProps
|
||||
} from './ProductCard';
|
||||
|
||||
// Category Card Components
|
||||
export {
|
||||
CategoryCard,
|
||||
CategoryCardVertical,
|
||||
CategoryCardHorizontal,
|
||||
CategoryCardSmall,
|
||||
CategoryCardLarge,
|
||||
CategoryCardIcon,
|
||||
type CategoryCardProps
|
||||
} from './CategoryCard';
|
||||
|
||||
// Card Grid Components
|
||||
export {
|
||||
CardGrid,
|
||||
CardGrid2,
|
||||
CardGrid3,
|
||||
CardGrid4,
|
||||
CardGridAuto,
|
||||
type CardGridProps,
|
||||
type GridColumns,
|
||||
type GridGap
|
||||
} from './CardGrid';
|
||||
export { TestimonialCard, TestimonialGrid, parseWpTestimonial, type TestimonialCardProps } from './TestimonialCard';
|
||||
@@ -1,243 +0,0 @@
|
||||
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;
|
||||
@@ -1,402 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,252 +0,0 @@
|
||||
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;
|
||||
@@ -1,438 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Container } from '../ui/Container';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
// Hero height options
|
||||
type HeroHeight = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'screen';
|
||||
|
||||
// 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;
|
||||
// Additional props for background color and overlay
|
||||
backgroundColor?: string;
|
||||
colorOverlay?: string;
|
||||
overlayStrength?: number;
|
||||
// WordPress Salient-specific props
|
||||
enableGradient?: boolean;
|
||||
gradientDirection?: 'left_to_right' | 'right_to_left' | 'top_to_bottom' | 'bottom_to_top';
|
||||
colorOverlay2?: string;
|
||||
parallaxBg?: boolean;
|
||||
parallaxBgSpeed?: 'slow' | 'fast' | 'medium';
|
||||
bgImageAnimation?: 'none' | 'zoom-out-reveal' | 'fade-in';
|
||||
topPadding?: string;
|
||||
bottomPadding?: string;
|
||||
textAlignment?: 'left' | 'center' | 'right';
|
||||
textColor?: 'light' | 'dark';
|
||||
shapeType?: string;
|
||||
scenePosition?: 'center' | 'top' | 'bottom';
|
||||
fullScreenRowPosition?: 'middle' | 'top' | 'bottom';
|
||||
// Video background props
|
||||
videoBg?: string;
|
||||
videoMp4?: string;
|
||||
videoWebm?: string;
|
||||
}
|
||||
|
||||
// Helper function to get height styles
|
||||
const getHeightStyles = (height: HeroHeight, fullScreenRowPosition?: string) => {
|
||||
const baseHeight = {
|
||||
sm: 'min-h-[300px] md:min-h-[400px]',
|
||||
md: 'min-h-[400px] md:min-h-[500px]',
|
||||
lg: 'min-h-[500px] md:min-h-[600px]',
|
||||
xl: 'min-h-[600px] md:min-h-[700px]',
|
||||
full: 'min-h-screen',
|
||||
screen: 'min-h-screen'
|
||||
}[height] || 'min-h-[500px] md:min-h-[600px]';
|
||||
|
||||
// Handle full screen positioning
|
||||
if (fullScreenRowPosition === 'middle') {
|
||||
return `${baseHeight} flex items-center justify-center`;
|
||||
} else if (fullScreenRowPosition === 'top') {
|
||||
return `${baseHeight} items-start justify-center pt-12`;
|
||||
} else if (fullScreenRowPosition === 'bottom') {
|
||||
return `${baseHeight} items-end justify-center pb-12`;
|
||||
}
|
||||
|
||||
return baseHeight;
|
||||
};
|
||||
|
||||
// Helper function to get variant styles
|
||||
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 = '',
|
||||
backgroundColor,
|
||||
colorOverlay,
|
||||
overlayStrength,
|
||||
enableGradient = false,
|
||||
gradientDirection = 'left_to_right',
|
||||
colorOverlay2,
|
||||
parallaxBg = false,
|
||||
parallaxBgSpeed = 'medium',
|
||||
bgImageAnimation = 'none',
|
||||
topPadding,
|
||||
bottomPadding,
|
||||
textAlignment = 'center',
|
||||
textColor = 'light',
|
||||
shapeType,
|
||||
scenePosition = 'center',
|
||||
fullScreenRowPosition,
|
||||
videoBg,
|
||||
videoMp4,
|
||||
videoWebm,
|
||||
}) => {
|
||||
const hasBackground = !!backgroundImage;
|
||||
const hasCTA = !!ctaText && !!ctaLink;
|
||||
const hasColorOverlay = !!colorOverlay;
|
||||
const hasGradient = !!enableGradient;
|
||||
const hasParallax = !!parallaxBg;
|
||||
const hasVideo = !!(videoMp4?.trim()) || !!(videoWebm?.trim());
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Calculate overlay opacity
|
||||
const overlayOpacityValue = overlayOpacity ?? (overlayStrength !== undefined ? overlayStrength : 0.5);
|
||||
|
||||
// Get text alignment
|
||||
const textAlignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[textAlignment];
|
||||
|
||||
// Get text color
|
||||
const textColorClass = textColor === 'light' ? 'text-white' : 'text-gray-900';
|
||||
const subtitleTextColorClass = textColor === 'light' ? 'text-gray-100' : 'text-gray-600';
|
||||
|
||||
// Get gradient direction
|
||||
const gradientDirectionClass = {
|
||||
'left_to_right': 'bg-gradient-to-r',
|
||||
'right_to_left': 'bg-gradient-to-l',
|
||||
'top_to_bottom': 'bg-gradient-to-b',
|
||||
'bottom_to_top': 'bg-gradient-to-t',
|
||||
}[gradientDirection];
|
||||
|
||||
// Get parallax speed
|
||||
const parallaxSpeedClass = {
|
||||
slow: 'parallax-slow',
|
||||
medium: 'parallax-medium',
|
||||
fast: 'parallax-fast',
|
||||
}[parallaxBgSpeed];
|
||||
|
||||
// Get background animation
|
||||
const bgAnimationClass = {
|
||||
none: '',
|
||||
'zoom-out-reveal': 'animate-zoom-out',
|
||||
'fade-in': 'animate-fade-in',
|
||||
}[bgImageAnimation];
|
||||
|
||||
// Calculate padding from props
|
||||
const customPaddingStyle = {
|
||||
paddingTop: topPadding || undefined,
|
||||
paddingBottom: bottomPadding || undefined,
|
||||
};
|
||||
|
||||
// Parallax effect handler
|
||||
useEffect(() => {
|
||||
if (!hasParallax || !heroRef.current) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!heroRef.current) return;
|
||||
|
||||
const rect = heroRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate offset based on scroll position
|
||||
const scrollProgress = (viewportHeight - rect.top) / (viewportHeight + rect.height);
|
||||
const offset = scrollProgress * 50; // Max 50px offset
|
||||
|
||||
// Apply to CSS variable
|
||||
heroRef.current.style.setProperty('--parallax-offset', `${offset}px`);
|
||||
};
|
||||
|
||||
handleScroll(); // Initial call
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [hasParallax]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden',
|
||||
getHeightStyles(height, fullScreenRowPosition),
|
||||
textAlignClass,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
...customPaddingStyle,
|
||||
}}
|
||||
>
|
||||
{/* Video Background */}
|
||||
{hasVideo && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{videoWebm && <source src={videoWebm} type="video/webm" />}
|
||||
{videoMp4 && <source src={videoMp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image with Parallax (fallback if no video) */}
|
||||
{hasBackground && !hasVideo && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
hasParallax && parallaxSpeedClass,
|
||||
bgAnimationClass
|
||||
)}>
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || title}
|
||||
fill
|
||||
priority
|
||||
className={cn(
|
||||
'object-cover',
|
||||
hasParallax && 'transform-gpu'
|
||||
)}
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackground && !backgroundColor && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getVariantStyles(variant)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
{hasGradient && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
gradientDirectionClass,
|
||||
'from-transparent via-transparent to-transparent'
|
||||
)}
|
||||
style={{
|
||||
opacity: overlayOpacityValue * 0.3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Overlay (from WordPress color_overlay) */}
|
||||
{hasColorOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay,
|
||||
opacity: overlayOpacityValue
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Second Color Overlay (for gradients) */}
|
||||
{colorOverlay2 && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay2,
|
||||
opacity: overlayOpacityValue * 0.5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Standard Overlay */}
|
||||
{overlay && hasBackground && !hasColorOverlay && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
getOverlayOpacity(overlayOpacityValue)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Shape Divider (bottom) */}
|
||||
{shapeType && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg
|
||||
className="w-full h-16 md:h-24 lg:h-32"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapeType === 'waves_opacity_alt' && (
|
||||
<path
|
||||
d="M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.8,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{shapeType === 'mountains' && (
|
||||
<path
|
||||
d="M0,0V60c100,0,150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-20 w-full">
|
||||
<Container
|
||||
maxWidth="6xl"
|
||||
padding="none"
|
||||
className={cn(
|
||||
'px-4 sm:px-6 md:px-8',
|
||||
// Add padding for full-height heroes
|
||||
height === 'full' || height === 'screen' ? 'py-12 md:py-20' : 'py-8 md:py-12'
|
||||
)}
|
||||
>
|
||||
{/* Title */}
|
||||
<h1
|
||||
className={cn(
|
||||
'font-bold leading-tight mb-4',
|
||||
'text-3xl sm:text-4xl md:text-5xl lg:text-6xl',
|
||||
'tracking-tight',
|
||||
textColorClass,
|
||||
// Enhanced contrast for overlays
|
||||
(hasBackground || hasColorOverlay || variant !== 'default') && 'drop-shadow-lg'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-lg sm:text-xl md:text-2xl',
|
||||
'mb-8 max-w-3xl mx-auto',
|
||||
'leading-relaxed',
|
||||
subtitleTextColorClass,
|
||||
(hasBackground || hasColorOverlay || variant !== 'default') && 'drop-shadow-md'
|
||||
)}
|
||||
>
|
||||
{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;
|
||||
@@ -1,350 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,530 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
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;
|
||||
// Additional props for background images and overlays
|
||||
backgroundImage?: string;
|
||||
backgroundAlt?: string;
|
||||
colorOverlay?: string;
|
||||
overlayOpacity?: number;
|
||||
backgroundColor?: string;
|
||||
// WordPress Salient-specific props
|
||||
enableGradient?: boolean;
|
||||
gradientDirection?: 'left_to_right' | 'right_to_left' | 'top_to_bottom' | 'bottom_to_top';
|
||||
colorOverlay2?: string;
|
||||
parallaxBg?: boolean;
|
||||
parallaxBgSpeed?: 'slow' | 'fast' | 'medium';
|
||||
bgImageAnimation?: 'none' | 'zoom-out-reveal' | 'fade-in';
|
||||
topPadding?: string;
|
||||
bottomPadding?: string;
|
||||
textAlignment?: 'left' | 'center' | 'right';
|
||||
textColor?: 'light' | 'dark';
|
||||
shapeType?: string;
|
||||
scenePosition?: 'center' | 'top' | 'bottom';
|
||||
fullScreenRowPosition?: 'middle' | 'top' | 'bottom';
|
||||
// Additional styling
|
||||
borderRadius?: string;
|
||||
boxShadow?: boolean;
|
||||
// Video background props
|
||||
videoBg?: string;
|
||||
videoMp4?: string;
|
||||
videoWebm?: string;
|
||||
}
|
||||
|
||||
// Helper function to get background styles
|
||||
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',
|
||||
backgroundImage,
|
||||
backgroundAlt = '',
|
||||
colorOverlay,
|
||||
overlayOpacity = 0.5,
|
||||
backgroundColor,
|
||||
enableGradient = false,
|
||||
gradientDirection = 'left_to_right',
|
||||
colorOverlay2,
|
||||
parallaxBg = false,
|
||||
parallaxBgSpeed = 'medium',
|
||||
bgImageAnimation = 'none',
|
||||
topPadding,
|
||||
bottomPadding,
|
||||
textAlignment = 'left',
|
||||
textColor = 'dark',
|
||||
shapeType,
|
||||
scenePosition = 'center',
|
||||
fullScreenRowPosition,
|
||||
borderRadius,
|
||||
boxShadow = false,
|
||||
videoBg,
|
||||
videoMp4,
|
||||
videoWebm,
|
||||
}) => {
|
||||
const hasBackgroundImage = !!backgroundImage;
|
||||
const hasColorOverlay = !!colorOverlay;
|
||||
const hasCustomBg = !!backgroundColor;
|
||||
const hasGradient = !!enableGradient;
|
||||
const hasParallax = !!parallaxBg;
|
||||
const hasVideo = !!(videoMp4?.trim()) || !!(videoWebm?.trim());
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Get text alignment
|
||||
const textAlignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[textAlignment];
|
||||
|
||||
// Get text color
|
||||
const textColorClass = textColor === 'light' ? 'text-white' : 'text-gray-900';
|
||||
|
||||
// Get gradient direction
|
||||
const gradientDirectionClass = {
|
||||
'left_to_right': 'bg-gradient-to-r',
|
||||
'right_to_left': 'bg-gradient-to-l',
|
||||
'top_to_bottom': 'bg-gradient-to-b',
|
||||
'bottom_to_top': 'bg-gradient-to-t',
|
||||
}[gradientDirection];
|
||||
|
||||
// Get parallax speed
|
||||
const parallaxSpeedClass = {
|
||||
slow: 'parallax-slow',
|
||||
medium: 'parallax-medium',
|
||||
fast: 'parallax-fast',
|
||||
}[parallaxBgSpeed];
|
||||
|
||||
// Get background animation
|
||||
const bgAnimationClass = {
|
||||
none: '',
|
||||
'zoom-out-reveal': 'animate-zoom-out',
|
||||
'fade-in': 'animate-fade-in',
|
||||
}[bgImageAnimation];
|
||||
|
||||
// Calculate padding from props
|
||||
const customPaddingStyle = {
|
||||
paddingTop: topPadding || undefined,
|
||||
paddingBottom: bottomPadding || undefined,
|
||||
};
|
||||
|
||||
// Base classes
|
||||
const baseClasses = cn(
|
||||
'w-full relative overflow-hidden',
|
||||
getPaddingStyles(padding),
|
||||
textAlignClass,
|
||||
textColorClass,
|
||||
boxShadow && 'shadow-xl',
|
||||
borderRadius && `rounded-${borderRadius}`,
|
||||
className
|
||||
);
|
||||
|
||||
// Background style (for solid colors)
|
||||
const backgroundStyle = hasCustomBg ? { backgroundColor, ...customPaddingStyle } : customPaddingStyle;
|
||||
|
||||
// Content wrapper classes
|
||||
const contentWrapperClasses = cn(
|
||||
'relative z-20 w-full',
|
||||
!fullWidth && 'container mx-auto px-4 md:px-6'
|
||||
);
|
||||
|
||||
// Parallax effect handler
|
||||
useEffect(() => {
|
||||
if (!hasParallax || !sectionRef.current) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!sectionRef.current) return;
|
||||
|
||||
const rect = sectionRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate offset based on scroll position
|
||||
const scrollProgress = (viewportHeight - rect.top) / (viewportHeight + rect.height);
|
||||
const offset = scrollProgress * 50; // Max 50px offset
|
||||
|
||||
// Apply to CSS variable
|
||||
sectionRef.current.style.setProperty('--parallax-offset', `${offset}px`);
|
||||
};
|
||||
|
||||
handleScroll(); // Initial call
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [hasParallax]);
|
||||
|
||||
const content = (
|
||||
<div ref={sectionRef} className={baseClasses} id={id} style={backgroundStyle}>
|
||||
{/* Video Background */}
|
||||
{hasVideo && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{videoWebm && <source src={videoWebm} type="video/webm" />}
|
||||
{videoMp4 && <source src={videoMp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image with Parallax (fallback if no video) */}
|
||||
{hasBackgroundImage && !hasVideo && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
hasParallax && parallaxSpeedClass,
|
||||
bgAnimationClass
|
||||
)}>
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || ''}
|
||||
fill
|
||||
className={cn(
|
||||
'object-cover',
|
||||
hasParallax && 'transform-gpu'
|
||||
)}
|
||||
sizes="100vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackgroundImage && !hasCustomBg && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getBackgroundStyles(background)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
{hasGradient && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
gradientDirectionClass,
|
||||
'from-transparent via-transparent to-transparent'
|
||||
)}
|
||||
style={{
|
||||
opacity: overlayOpacity * 0.3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Overlay (from WordPress color_overlay) */}
|
||||
{hasColorOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay,
|
||||
opacity: overlayOpacity
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Second Color Overlay (for gradients) */}
|
||||
{colorOverlay2 && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay2,
|
||||
opacity: overlayOpacity * 0.5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shape Divider (bottom) */}
|
||||
{shapeType && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg
|
||||
className="w-full h-16 md:h-24 lg:h-32"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapeType === 'waves_opacity_alt' && (
|
||||
<path
|
||||
d="M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.8,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{shapeType === 'mountains' && (
|
||||
<path
|
||||
d="M0,0V60c100,0,150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={contentWrapperClasses}>
|
||||
{fullWidth ? children : (
|
||||
<Container maxWidth="6xl" padding="none">
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (Component !== 'section') {
|
||||
return (
|
||||
<Component className={baseClasses} id={id} style={backgroundStyle}>
|
||||
{/* Video Background */}
|
||||
{hasVideo && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
{videoWebm && <source src={videoWebm} type="video/webm" />}
|
||||
{videoMp4 && <source src={videoMp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image with Parallax (fallback if no video) */}
|
||||
{hasBackgroundImage && !hasVideo && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
hasParallax && parallaxSpeedClass,
|
||||
bgAnimationClass
|
||||
)}>
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt={backgroundAlt || ''}
|
||||
fill
|
||||
className={cn(
|
||||
'object-cover',
|
||||
hasParallax && 'transform-gpu'
|
||||
)}
|
||||
sizes="100vw"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Variant (if no image) */}
|
||||
{!hasBackgroundImage && !hasCustomBg && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 z-0',
|
||||
getBackgroundStyles(background)
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
{hasGradient && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10',
|
||||
gradientDirectionClass,
|
||||
'from-transparent via-transparent to-transparent'
|
||||
)}
|
||||
style={{
|
||||
opacity: overlayOpacity * 0.3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color Overlay */}
|
||||
{hasColorOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay,
|
||||
opacity: overlayOpacity
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Second Color Overlay */}
|
||||
{colorOverlay2 && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundColor: colorOverlay2,
|
||||
opacity: overlayOpacity * 0.5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shape Divider */}
|
||||
{shapeType && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg
|
||||
className="w-full h-16 md:h-24 lg:h-32"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapeType === 'waves_opacity_alt' && (
|
||||
<path
|
||||
d="M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.8,104.45-29.34C989.49,25,1113-14.29,1200,52.47V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{shapeType === 'mountains' && (
|
||||
<path
|
||||
d="M0,0V60c100,0,150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20s150,20,200,20s100-20,200-20V0Z"
|
||||
opacity=".25"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={contentWrapperClasses}>
|
||||
{fullWidth ? children : (
|
||||
<Container maxWidth="6xl" padding="none">
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,359 +0,0 @@
|
||||
# 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,6 +0,0 @@
|
||||
// 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';
|
||||
@@ -1,401 +0,0 @@
|
||||
# KLZ Forms System - Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
A comprehensive, production-ready form system for the KLZ Cables Next.js application, providing consistent form experiences across the entire platform. Built with TypeScript, accessibility, and internationalization in mind.
|
||||
|
||||
## ✅ Completed Components
|
||||
|
||||
### Core Components (10/10)
|
||||
|
||||
1. **FormField** (`FormField.tsx`)
|
||||
- Universal wrapper for all form field types
|
||||
- Supports: text, email, tel, textarea, select, checkbox, radio, number, password, date, time, url
|
||||
- Integrates label, input, help text, and error display
|
||||
- Type-safe with full TypeScript support
|
||||
|
||||
2. **FormLabel** (`FormLabel.tsx`)
|
||||
- Consistent label styling
|
||||
- Required field indicators (*)
|
||||
- Optional text support
|
||||
- Help text integration
|
||||
- Accessibility attributes
|
||||
|
||||
3. **FormInput** (`FormInput.tsx`)
|
||||
- Base input component
|
||||
- All HTML5 input types
|
||||
- Prefix/suffix icon support
|
||||
- Clear button functionality
|
||||
- Focus and validation states
|
||||
|
||||
4. **FormTextarea** (`FormTextarea.tsx`)
|
||||
- Textarea with resize options
|
||||
- Character counter
|
||||
- Auto-resize functionality
|
||||
- Validation states
|
||||
- Configurable min/max height
|
||||
|
||||
5. **FormSelect** (`FormSelect.tsx`)
|
||||
- Select dropdown
|
||||
- Placeholder option
|
||||
- Multi-select support
|
||||
- Search/filter for large lists
|
||||
- Custom styling
|
||||
|
||||
6. **FormCheckbox** (`FormCheckbox.tsx`)
|
||||
- Single checkbox
|
||||
- Checkbox groups
|
||||
- Indeterminate state
|
||||
- Custom styling
|
||||
- Label integration
|
||||
|
||||
7. **FormRadio** (`FormRadio.tsx`)
|
||||
- Radio button groups
|
||||
- Custom styling
|
||||
- Keyboard navigation
|
||||
- Horizontal/vertical layouts
|
||||
- Description support
|
||||
|
||||
8. **FormError** (`FormError.tsx`)
|
||||
- Error message display
|
||||
- Multiple errors support
|
||||
- Inline, block, and toast variants
|
||||
- Animation support
|
||||
- Accessibility (aria-live)
|
||||
|
||||
9. **FormSuccess** (`FormSuccess.tsx`)
|
||||
- Success message display
|
||||
- Auto-dismiss option
|
||||
- Icon support
|
||||
- Inline, block, and toast variants
|
||||
- Animation support
|
||||
|
||||
10. **FormExamples** (`FormExamples.tsx`)
|
||||
- Complete usage examples
|
||||
- 5 different form patterns
|
||||
- Real-world scenarios
|
||||
- Best practices demonstration
|
||||
|
||||
### Form Hooks (3/3)
|
||||
|
||||
1. **useForm** (`hooks/useForm.ts`)
|
||||
- Complete form state management
|
||||
- Validation integration
|
||||
- Submission handling
|
||||
- Error management
|
||||
- Helper methods (reset, setAllTouched, etc.)
|
||||
- getFormProps utility
|
||||
|
||||
2. **useFormField** (`hooks/useFormField.ts`)
|
||||
- Individual field state management
|
||||
- Validation integration
|
||||
- Touch/dirty tracking
|
||||
- Change handlers
|
||||
- Helper utilities
|
||||
|
||||
3. **useFormValidation** (`hooks/useFormValidation.ts`)
|
||||
- Validation logic
|
||||
- Rule definitions
|
||||
- Field and form validation
|
||||
- Custom validators
|
||||
- Error formatting
|
||||
|
||||
### Infrastructure
|
||||
|
||||
1. **Index File** (`index.ts`)
|
||||
- All exports in one place
|
||||
- Type exports
|
||||
- Convenience re-exports
|
||||
|
||||
2. **Documentation** (`README.md`)
|
||||
- Complete usage guide
|
||||
- Examples
|
||||
- Best practices
|
||||
- Troubleshooting
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
### Validation System
|
||||
```typescript
|
||||
{
|
||||
required: boolean | string;
|
||||
minLength: { value: number, message: string };
|
||||
maxLength: { value: number, message: string };
|
||||
pattern: { value: RegExp, message: string };
|
||||
min: { value: number, message: string };
|
||||
max: { value: number, message: string };
|
||||
email: boolean | string;
|
||||
url: boolean | string;
|
||||
number: boolean | string;
|
||||
custom: (value) => string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Form State Management
|
||||
- Automatic validation on change
|
||||
- Touch tracking for error display
|
||||
- Dirty state tracking
|
||||
- Submit state management
|
||||
- Reset functionality
|
||||
|
||||
### Accessibility
|
||||
- ARIA attributes
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
- Focus management
|
||||
- Required field indicators
|
||||
|
||||
### Internationalization
|
||||
- Ready for i18n
|
||||
- Error messages can be translated
|
||||
- Label and help text support
|
||||
|
||||
### Styling
|
||||
- Uses design system tokens
|
||||
- Consistent with existing components
|
||||
- Responsive design
|
||||
- Dark mode ready
|
||||
|
||||
## 📦 Usage Examples
|
||||
|
||||
### Basic Contact Form
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { name: '', email: '', message: '' },
|
||||
validationRules: {
|
||||
name: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
message: { required: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await sendEmail(values);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<FormField name="name" label="Name" required {...form.getFieldProps('name')} />
|
||||
<FormField type="email" name="email" label="Email" required {...form.getFieldProps('email')} />
|
||||
<FormField type="textarea" name="message" label="Message" required {...form.getFieldProps('message')} />
|
||||
<Button type="submit" disabled={!form.isValid} loading={form.isSubmitting}>
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
```
|
||||
|
||||
### Registration Form
|
||||
```tsx
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
terms: false,
|
||||
},
|
||||
validationRules: {
|
||||
firstName: { required: true, minLength: { value: 2 } },
|
||||
lastName: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
password: {
|
||||
required: true,
|
||||
minLength: { value: 8 },
|
||||
pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/ }
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
custom: (value) => value === form.values.password ? null : 'Passwords do not match'
|
||||
},
|
||||
terms: {
|
||||
required: 'You must accept terms',
|
||||
custom: (value) => value ? null : 'Required'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await registerUser(values);
|
||||
alert('Registered!');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Search and Filter
|
||||
```tsx
|
||||
const form = useForm({
|
||||
initialValues: { search: '', category: '', status: '' },
|
||||
validationRules: {},
|
||||
onSubmit: async (values) => {
|
||||
await performSearch(values);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search..."
|
||||
showClear
|
||||
{...form.getFieldProps('search')}
|
||||
/>
|
||||
<FormField
|
||||
type="select"
|
||||
name="category"
|
||||
options={categoryOptions}
|
||||
{...form.getFieldProps('category')}
|
||||
/>
|
||||
<Button type="submit">Search</Button>
|
||||
</form>
|
||||
);
|
||||
```
|
||||
|
||||
## 🎨 Design System Integration
|
||||
|
||||
### Colors
|
||||
- Primary: `--color-primary`
|
||||
- Danger: `--color-danger`
|
||||
- Success: `--color-success`
|
||||
- Neutral: `--color-neutral-dark`, `--color-neutral-light`
|
||||
|
||||
### Spacing
|
||||
- Consistent with design system
|
||||
- Uses `--spacing-sm`, `--spacing-md`, `--spacing-lg`
|
||||
|
||||
### Typography
|
||||
- Font sizes: `--font-size-sm`, `--font-size-base`
|
||||
- Font weights: `--font-weight-medium`, `--font-weight-semibold`
|
||||
|
||||
### Borders & Radius
|
||||
- Border radius: `--radius-md`
|
||||
- Transitions: `--transition-fast`
|
||||
|
||||
## 🚀 Benefits
|
||||
|
||||
1. **Consistency**: All forms look and behave the same
|
||||
2. **Type Safety**: Full TypeScript support prevents errors
|
||||
3. **Accessibility**: Built-in ARIA and keyboard support
|
||||
4. **Validation**: Comprehensive validation system
|
||||
5. **Maintainability**: Centralized form logic
|
||||
6. **Developer Experience**: Easy to use, hard to misuse
|
||||
7. **Performance**: Optimized re-renders
|
||||
8. **Flexibility**: Works with any form structure
|
||||
|
||||
## 📊 File Structure
|
||||
|
||||
```
|
||||
components/forms/
|
||||
├── FormField.tsx # Main wrapper component
|
||||
├── FormLabel.tsx # Label component
|
||||
├── FormInput.tsx # Input component
|
||||
├── FormTextarea.tsx # Textarea component
|
||||
├── FormSelect.tsx # Select component
|
||||
├── FormCheckbox.tsx # Checkbox component
|
||||
├── FormRadio.tsx # Radio component
|
||||
├── FormError.tsx # Error display
|
||||
├── FormSuccess.tsx # Success display
|
||||
├── FormExamples.tsx # Usage examples
|
||||
├── index.ts # Exports
|
||||
├── README.md # Documentation
|
||||
├── FORM_SYSTEM_SUMMARY.md # This file
|
||||
└── hooks/
|
||||
├── useForm.ts # Main form hook
|
||||
├── useFormField.ts # Field hook
|
||||
└── useFormValidation.ts # Validation logic
|
||||
```
|
||||
|
||||
## 🔄 Migration Path
|
||||
|
||||
### From Legacy Forms
|
||||
```tsx
|
||||
// Old
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className={error ? 'error' : ''}
|
||||
/>
|
||||
|
||||
// New
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
### From Manual Validation
|
||||
```tsx
|
||||
// Old
|
||||
const validate = () => {
|
||||
const errors = {};
|
||||
if (!email) errors.email = 'Required';
|
||||
return errors;
|
||||
}
|
||||
|
||||
// New
|
||||
const form = useForm({
|
||||
validationRules: {
|
||||
email: { required: true, email: true }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Integration**: Replace existing ContactForm with new system
|
||||
2. **Testing**: Add unit tests for all components
|
||||
3. **Documentation**: Add JSDoc comments
|
||||
4. **Examples**: Create more real-world examples
|
||||
5. **Performance**: Add memoization where needed
|
||||
6. **i18n**: Integrate with translation system
|
||||
|
||||
## ✨ Quality Checklist
|
||||
|
||||
- [x] All components created
|
||||
- [x] All hooks implemented
|
||||
- [x] TypeScript types defined
|
||||
- [x] Accessibility features included
|
||||
- [x] Validation system complete
|
||||
- [x] Examples provided
|
||||
- [x] Documentation written
|
||||
- [x] Design system integration
|
||||
- [x] Error handling
|
||||
- [x] Loading states
|
||||
- [x] Success states
|
||||
- [x] Reset functionality
|
||||
- [x] Touch/dirty tracking
|
||||
- [x] Character counting
|
||||
- [x] Auto-resize textarea
|
||||
- [x] Search in select
|
||||
- [x] Multi-select support
|
||||
- [x] Checkbox groups
|
||||
- [x] Radio groups
|
||||
- [x] Indeterminate state
|
||||
- [x] Clear buttons
|
||||
- [x] Icon support
|
||||
- [x] Help text
|
||||
- [x] Required indicators
|
||||
- [x] Multiple error display
|
||||
- [x] Toast notifications
|
||||
- [x] Animations
|
||||
- [x] Focus management
|
||||
- [x] Keyboard navigation
|
||||
- [x] Screen reader support
|
||||
|
||||
## 🎉 Result
|
||||
|
||||
A complete, production-ready form system that provides:
|
||||
|
||||
- **10** reusable form components
|
||||
- **3** powerful hooks
|
||||
- **5** complete examples
|
||||
- **Full** TypeScript support
|
||||
- **Complete** accessibility
|
||||
- **Comprehensive** documentation
|
||||
|
||||
All components are ready to use and follow the KLZ Cables design system patterns.
|
||||
@@ -1,259 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormCheckbox Component
|
||||
* Single and group checkboxes with indeterminate state and custom styling
|
||||
*/
|
||||
|
||||
export interface CheckboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FormCheckboxProps {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
checked?: boolean;
|
||||
indeterminate?: boolean;
|
||||
options?: CheckboxOption[];
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
containerClassName?: string;
|
||||
checkboxClassName?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const FormCheckbox: React.FC<FormCheckboxProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
checked = false,
|
||||
indeterminate = false,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
containerClassName,
|
||||
checkboxClassName,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
}) => {
|
||||
const [internalChecked, setInternalChecked] = useState(checked);
|
||||
const checkboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
// Handle indeterminate state
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate, internalChecked]);
|
||||
|
||||
// Sync internal state with prop
|
||||
useEffect(() => {
|
||||
setInternalChecked(checked);
|
||||
}, [checked]);
|
||||
|
||||
const isGroup = Array.isArray(options) && options.length > 0;
|
||||
|
||||
const handleSingleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newChecked = e.target.checked;
|
||||
setInternalChecked(newChecked);
|
||||
if (onChange && !isGroup) {
|
||||
// For single checkbox, call onChange with boolean
|
||||
// But to maintain consistency, we'll treat it as a group with one option
|
||||
if (newChecked) {
|
||||
onChange([name || 'checkbox']);
|
||||
} else {
|
||||
onChange([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const optionValue = e.target.value;
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
let newValue: string[];
|
||||
|
||||
if (isChecked) {
|
||||
newValue = [...value, optionValue];
|
||||
} else {
|
||||
newValue = value.filter((v) => v !== optionValue);
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseCheckboxClasses = cn(
|
||||
'w-4 h-4 rounded border transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary',
|
||||
{
|
||||
'border-neutral-dark bg-neutral-light': !internalChecked && !hasError && !indeterminate,
|
||||
'border-primary bg-primary text-white': internalChecked && !hasError,
|
||||
'border-danger bg-danger text-white': hasError,
|
||||
'border-primary bg-primary/50 text-white': indeterminate,
|
||||
'opacity-60 cursor-not-allowed': disabled,
|
||||
'cursor-pointer': !disabled,
|
||||
},
|
||||
checkboxClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-2',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const groupContainerClasses = cn(
|
||||
'flex flex-col gap-2',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const singleWrapperClasses = cn(
|
||||
'flex items-start gap-2',
|
||||
{
|
||||
'opacity-60': disabled,
|
||||
}
|
||||
);
|
||||
|
||||
const labelClasses = cn(
|
||||
'text-sm font-medium leading-none',
|
||||
{
|
||||
'text-text-primary': !hasError,
|
||||
'text-danger': hasError,
|
||||
}
|
||||
);
|
||||
|
||||
// Single checkbox
|
||||
if (!isGroup) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className={singleWrapperClasses}>
|
||||
<input
|
||||
ref={checkboxRef}
|
||||
type="checkbox"
|
||||
id={inputId}
|
||||
name={name}
|
||||
checked={internalChecked}
|
||||
onChange={handleSingleChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
||||
className={baseCheckboxClasses}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={labelClasses}>
|
||||
{label}
|
||||
{required && <span className="text-danger ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary mt-1" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Checkbox group
|
||||
const groupLabelId = inputId ? `${inputId}-group-label` : undefined;
|
||||
const allSelected = options.every(opt => value.includes(opt.value));
|
||||
const someSelected = options.some(opt => value.includes(opt.value)) && !allSelected;
|
||||
|
||||
// Update indeterminate state for group select all
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current && someSelected) {
|
||||
checkboxRef.current.indeterminate = true;
|
||||
}
|
||||
}, [someSelected, value, options]);
|
||||
|
||||
return (
|
||||
<div className={groupContainerClasses}>
|
||||
{label && (
|
||||
<div className="mb-2">
|
||||
<FormLabel id={groupLabelId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2" role="group" aria-labelledby={groupLabelId}>
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.value}`;
|
||||
const isChecked = value.includes(option.value);
|
||||
|
||||
return (
|
||||
<div key={option.value} className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={optionId}
|
||||
value={option.value}
|
||||
checked={isChecked}
|
||||
onChange={handleGroupChange}
|
||||
disabled={disabled || option.disabled}
|
||||
required={required && value.length === 0}
|
||||
aria-invalid={hasError}
|
||||
className={cn(
|
||||
baseCheckboxClasses,
|
||||
option.disabled && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor={optionId}
|
||||
className={cn(
|
||||
labelClasses,
|
||||
option.disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary mt-1" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormCheckbox.displayName = 'FormCheckbox';
|
||||
|
||||
export default FormCheckbox;
|
||||
@@ -1,89 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FormError Component
|
||||
* Display error messages with different variants and animations
|
||||
*/
|
||||
|
||||
export interface FormErrorProps {
|
||||
errors?: string | string[];
|
||||
variant?: 'inline' | 'block' | 'toast';
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
animate?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const FormError: React.FC<FormErrorProps> = ({
|
||||
errors,
|
||||
variant = 'inline',
|
||||
className,
|
||||
showIcon = true,
|
||||
animate = true,
|
||||
id,
|
||||
}) => {
|
||||
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorArray = Array.isArray(errors) ? errors : [errors];
|
||||
const hasMultipleErrors = errorArray.length > 1;
|
||||
|
||||
const baseClasses = {
|
||||
inline: 'text-sm text-danger mt-1',
|
||||
block: 'p-3 bg-danger/10 border border-danger/20 rounded-md text-danger text-sm',
|
||||
toast: 'fixed bottom-4 right-4 p-4 bg-danger text-white rounded-lg shadow-lg max-w-md z-tooltip animate-slide-up',
|
||||
};
|
||||
|
||||
const animationClasses = animate ? 'animate-fade-in' : '';
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
className="w-4 h-4 mr-1 inline-block"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
id={id}
|
||||
className={cn(
|
||||
baseClasses[variant],
|
||||
animationClasses,
|
||||
'transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasMultipleErrors ? (
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{errorArray.map((error, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
{showIcon && <Icon />}
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-start">
|
||||
{showIcon && <Icon />}
|
||||
<span>{errorArray[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormError.displayName = 'FormError';
|
||||
|
||||
export default FormError;
|
||||
@@ -1,795 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
FormField,
|
||||
useForm,
|
||||
useFormWithHelpers,
|
||||
type ValidationRules
|
||||
} from './index';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
|
||||
/**
|
||||
* Form Examples
|
||||
* Comprehensive examples showing all form patterns and usage
|
||||
*/
|
||||
|
||||
// Example 1: Simple Contact Form
|
||||
export const ContactFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
},
|
||||
validationRules: {
|
||||
name: { required: 'Name is required', minLength: { value: 2, message: 'Name must be at least 2 characters' } },
|
||||
email: { required: 'Email is required', email: true },
|
||||
message: { required: 'Message is required', minLength: { value: 10, message: 'Message must be at least 10 characters' } },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Form submitted:', values);
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
alert('Form submitted successfully!');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Contact Form</h3>
|
||||
<p className="text-sm text-text-secondary">Simple contact form with validation</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="name"
|
||||
label="Full Name"
|
||||
placeholder="Enter your name"
|
||||
required
|
||||
value={form.values.name}
|
||||
error={form.errors.name?.[0]}
|
||||
touched={form.touched.name}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
touched={form.touched.email}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="textarea"
|
||||
name="message"
|
||||
label="Message"
|
||||
placeholder="How can we help you?"
|
||||
required
|
||||
rows={5}
|
||||
showCharCount
|
||||
maxLength={500}
|
||||
value={form.values.message}
|
||||
error={form.errors.message?.[0]}
|
||||
touched={form.touched.message}
|
||||
onChange={(e) => form.setFieldValue('message', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Send Message
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={form.reset}
|
||||
disabled={form.isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 2: Registration Form with Multiple Field Types
|
||||
export const RegistrationFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
country: '',
|
||||
interests: [] as string[],
|
||||
newsletter: false,
|
||||
terms: false,
|
||||
},
|
||||
validationRules: {
|
||||
firstName: { required: true, minLength: { value: 2, message: 'Too short' } },
|
||||
lastName: { required: true, minLength: { value: 2, message: 'Too short' } },
|
||||
email: { required: true, email: true },
|
||||
password: {
|
||||
required: true,
|
||||
minLength: { value: 8, message: 'Password must be at least 8 characters' },
|
||||
pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, message: 'Must contain uppercase, lowercase, and number' }
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
custom: (value) => value === form.values.password ? null : 'Passwords do not match'
|
||||
},
|
||||
country: { required: 'Please select your country' },
|
||||
interests: { required: 'Select at least one interest' },
|
||||
newsletter: {},
|
||||
terms: {
|
||||
required: 'You must accept the terms',
|
||||
custom: (value) => value ? null : 'You must accept the terms'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Registration:', values);
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Registration successful!');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const countryOptions = [
|
||||
{ value: 'de', label: 'Germany' },
|
||||
{ value: 'us', label: 'United States' },
|
||||
{ value: 'uk', label: 'United Kingdom' },
|
||||
{ value: 'fr', label: 'France' },
|
||||
];
|
||||
|
||||
const interestOptions = [
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
{ value: 'business', label: 'Business' },
|
||||
{ value: 'innovation', label: 'Innovation' },
|
||||
{ value: 'sustainability', label: 'Sustainability' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Registration Form</h3>
|
||||
<p className="text-sm text-text-secondary">Complete registration with multiple field types</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
required
|
||||
value={form.values.firstName}
|
||||
error={form.errors.firstName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('firstName', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="text"
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
required
|
||||
value={form.values.lastName}
|
||||
error={form.errors.lastName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('lastName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
required
|
||||
helpText="Min 8 chars with uppercase, lowercase, and number"
|
||||
value={form.values.password}
|
||||
error={form.errors.password?.[0]}
|
||||
onChange={(e) => form.setFieldValue('password', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
required
|
||||
value={form.values.confirmPassword}
|
||||
error={form.errors.confirmPassword?.[0]}
|
||||
onChange={(e) => form.setFieldValue('confirmPassword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="country"
|
||||
label="Country"
|
||||
required
|
||||
options={countryOptions}
|
||||
value={form.values.country}
|
||||
error={form.errors.country?.[0]}
|
||||
onChange={(e) => form.setFieldValue('country', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="interests"
|
||||
label="Areas of Interest"
|
||||
required
|
||||
options={interestOptions}
|
||||
value={form.values.interests}
|
||||
error={form.errors.interests?.[0]}
|
||||
onChange={(values) => form.setFieldValue('interests', values)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="newsletter"
|
||||
label="Subscribe to newsletter"
|
||||
checked={form.values.newsletter}
|
||||
onChange={(values) => form.setFieldValue('newsletter', values.length > 0)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="terms"
|
||||
label="I accept the terms and conditions"
|
||||
required
|
||||
checked={form.values.terms}
|
||||
error={form.errors.terms?.[0]}
|
||||
onChange={(values) => form.setFieldValue('terms', values.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-neutral-dark">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={form.reset}
|
||||
disabled={form.isSubmitting}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 3: Search and Filter Form
|
||||
export const SearchFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
search: '',
|
||||
category: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
},
|
||||
validationRules: {
|
||||
search: {},
|
||||
category: {},
|
||||
status: {},
|
||||
sortBy: {},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Search filters:', values);
|
||||
// Handle search/filter logic
|
||||
},
|
||||
});
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'cables', label: 'Cables' },
|
||||
{ value: 'connectors', label: 'Connectors' },
|
||||
{ value: 'accessories', label: 'Accessories' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'name', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'date', label: 'Date (Newest)' },
|
||||
{ value: 'date-asc', label: 'Date (Oldest)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Search & Filter</h3>
|
||||
<p className="text-sm text-text-secondary">Advanced search with multiple filters</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
label="Search"
|
||||
placeholder="Search products..."
|
||||
prefix={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
showClear
|
||||
value={form.values.search}
|
||||
onChange={(e) => form.setFieldValue('search', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
options={categoryOptions}
|
||||
value={form.values.category}
|
||||
onChange={(e) => form.setFieldValue('category', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="status"
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
value={form.values.status}
|
||||
onChange={(e) => form.setFieldValue('status', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
<FormField
|
||||
type="select"
|
||||
name="sortBy"
|
||||
label="Sort By"
|
||||
options={sortOptions}
|
||||
value={form.values.sortBy}
|
||||
onChange={(e) => form.setFieldValue('sortBy', e.target.value)}
|
||||
containerClassName="w-48"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Apply Filters
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={form.reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 4: Radio Button Form
|
||||
export const RadioFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
paymentMethod: '',
|
||||
shippingMethod: '',
|
||||
deliveryTime: '',
|
||||
},
|
||||
validationRules: {
|
||||
paymentMethod: { required: 'Please select a payment method' },
|
||||
shippingMethod: { required: 'Please select a shipping method' },
|
||||
deliveryTime: { required: 'Please select preferred delivery time' },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Selections:', values);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
alert('Preferences saved!');
|
||||
},
|
||||
});
|
||||
|
||||
const paymentOptions = [
|
||||
{ value: 'credit-card', label: 'Credit Card', description: 'Visa, Mastercard, Amex' },
|
||||
{ value: 'paypal', label: 'PayPal', description: 'Secure payment via PayPal' },
|
||||
{ value: 'bank-transfer', label: 'Bank Transfer', description: 'Direct bank transfer' },
|
||||
];
|
||||
|
||||
const shippingOptions = [
|
||||
{ value: 'standard', label: 'Standard (5-7 days)', description: 'Free shipping on orders over €50' },
|
||||
{ value: 'express', label: 'Express (2-3 days)', description: '€9.99 shipping fee' },
|
||||
{ value: 'overnight', label: 'Overnight', description: '€24.99 shipping fee' },
|
||||
];
|
||||
|
||||
const deliveryOptions = [
|
||||
{ value: 'morning', label: 'Morning (8am-12pm)' },
|
||||
{ value: 'afternoon', label: 'Afternoon (12pm-6pm)' },
|
||||
{ value: 'evening', label: 'Evening (6pm-9pm)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Preferences Selection</h3>
|
||||
<p className="text-sm text-text-secondary">Radio buttons for single choice selection</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-6">
|
||||
<FormField
|
||||
type="radio"
|
||||
name="paymentMethod"
|
||||
label="Payment Method"
|
||||
required
|
||||
options={paymentOptions}
|
||||
value={form.values.paymentMethod}
|
||||
error={form.errors.paymentMethod?.[0]}
|
||||
onChange={(value) => form.setFieldValue('paymentMethod', value)}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="radio"
|
||||
name="shippingMethod"
|
||||
label="Shipping Method"
|
||||
required
|
||||
options={shippingOptions}
|
||||
value={form.values.shippingMethod}
|
||||
error={form.errors.shippingMethod?.[0]}
|
||||
onChange={(value) => form.setFieldValue('shippingMethod', value)}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="radio"
|
||||
name="deliveryTime"
|
||||
label="Preferred Delivery Time"
|
||||
required
|
||||
options={deliveryOptions}
|
||||
value={form.values.deliveryTime}
|
||||
error={form.errors.deliveryTime?.[0]}
|
||||
onChange={(value) => form.setFieldValue('deliveryTime', value)}
|
||||
layout="horizontal"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-neutral-dark">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Save Preferences
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={form.reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 5: Complete Form with All Features
|
||||
export const CompleteFormExample: React.FC = () => {
|
||||
const [submittedData, setSubmittedData] = useState<any>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
// Text inputs
|
||||
fullName: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
|
||||
// Textarea
|
||||
description: '',
|
||||
|
||||
// Select
|
||||
industry: '',
|
||||
budget: '',
|
||||
|
||||
// Checkbox group
|
||||
services: [] as string[],
|
||||
|
||||
// Radio
|
||||
contactPreference: '',
|
||||
|
||||
// Single checkbox
|
||||
agreeToTerms: false,
|
||||
},
|
||||
validationRules: {
|
||||
fullName: { required: true, minLength: { value: 3, message: 'Minimum 3 characters' } },
|
||||
phone: { required: true, pattern: { value: /^[0-9+\-\s()]+$/, message: 'Invalid phone format' } },
|
||||
website: { url: 'Invalid URL format' },
|
||||
description: { required: true, minLength: { value: 20, message: 'Please provide more details' } },
|
||||
industry: { required: true },
|
||||
budget: { required: true },
|
||||
services: { required: 'Select at least one service' },
|
||||
contactPreference: { required: true },
|
||||
agreeToTerms: {
|
||||
required: 'You must accept the terms',
|
||||
custom: (value) => value ? null : 'Required'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Complete form submitted:', values);
|
||||
setSubmittedData(values);
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Form submitted successfully! Check console for data.');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const industryOptions = [
|
||||
{ value: '', label: 'Select Industry' },
|
||||
{ value: 'manufacturing', label: 'Manufacturing' },
|
||||
{ value: 'construction', label: 'Construction' },
|
||||
{ value: 'energy', label: 'Energy' },
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
];
|
||||
|
||||
const budgetOptions = [
|
||||
{ value: '', label: 'Select Budget Range' },
|
||||
{ value: 'small', label: '€1,000 - €5,000' },
|
||||
{ value: 'medium', label: '€5,000 - €20,000' },
|
||||
{ value: 'large', label: '€20,000+' },
|
||||
];
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: 'consulting', label: 'Consulting' },
|
||||
{ value: 'installation', label: 'Installation' },
|
||||
{ value: 'maintenance', label: 'Maintenance' },
|
||||
{ value: 'training', label: 'Training' },
|
||||
{ value: 'support', label: '24/7 Support' },
|
||||
];
|
||||
|
||||
const contactOptions = [
|
||||
{ value: 'email', label: 'Email', description: 'We\'ll respond within 24 hours' },
|
||||
{ value: 'phone', label: 'Phone', description: 'Call us during business hours' },
|
||||
{ value: 'both', label: 'Both', description: 'Email and phone contact' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Complete Form Example</h3>
|
||||
<p className="text-sm text-text-secondary">All form components working together</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-lg">Personal Information</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="fullName"
|
||||
label="Full Name"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
value={form.values.fullName}
|
||||
error={form.errors.fullName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('fullName', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="tel"
|
||||
name="phone"
|
||||
label="Phone Number"
|
||||
required
|
||||
placeholder="+1 234 567 8900"
|
||||
value={form.values.phone}
|
||||
error={form.errors.phone?.[0]}
|
||||
onChange={(e) => form.setFieldValue('phone', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="url"
|
||||
name="website"
|
||||
label="Website (Optional)"
|
||||
placeholder="https://example.com"
|
||||
showClear
|
||||
value={form.values.website}
|
||||
error={form.errors.website?.[0]}
|
||||
onChange={(e) => form.setFieldValue('website', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Business Information */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-dark">
|
||||
<h4 className="font-semibold text-lg">Business Information</h4>
|
||||
|
||||
<FormField
|
||||
type="textarea"
|
||||
name="description"
|
||||
label="Project Description"
|
||||
required
|
||||
rows={5}
|
||||
showCharCount
|
||||
maxLength={500}
|
||||
placeholder="Describe your project requirements..."
|
||||
value={form.values.description}
|
||||
error={form.errors.description?.[0]}
|
||||
onChange={(e) => form.setFieldValue('description', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="select"
|
||||
name="industry"
|
||||
label="Industry"
|
||||
required
|
||||
options={industryOptions}
|
||||
value={form.values.industry}
|
||||
error={form.errors.industry?.[0]}
|
||||
onChange={(e) => form.setFieldValue('industry', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="budget"
|
||||
label="Budget Range"
|
||||
required
|
||||
options={budgetOptions}
|
||||
value={form.values.budget}
|
||||
error={form.errors.budget?.[0]}
|
||||
onChange={(e) => form.setFieldValue('budget', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services & Preferences */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-dark">
|
||||
<h4 className="font-semibold text-lg">Services & Preferences</h4>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="services"
|
||||
label="Required Services"
|
||||
required
|
||||
options={serviceOptions}
|
||||
value={form.values.services}
|
||||
error={form.errors.services?.[0]}
|
||||
onChange={(values) => form.setFieldValue('services', values)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="radio"
|
||||
name="contactPreference"
|
||||
label="Preferred Contact Method"
|
||||
required
|
||||
options={contactOptions}
|
||||
value={form.values.contactPreference}
|
||||
error={form.errors.contactPreference?.[0]}
|
||||
onChange={(value) => form.setFieldValue('contactPreference', value)}
|
||||
layout="vertical"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-dark">
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="agreeToTerms"
|
||||
label="I agree to the terms and conditions and privacy policy"
|
||||
required
|
||||
checked={form.values.agreeToTerms}
|
||||
error={form.errors.agreeToTerms?.[0]}
|
||||
onChange={(values) => form.setFieldValue('agreeToTerms', values.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-neutral-dark">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Submit Application
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={form.reset}
|
||||
disabled={form.isSubmitting}
|
||||
>
|
||||
Reset Form
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Debug Output */}
|
||||
{submittedData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h4 className="font-semibold">Submitted Data</h4>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<pre className="bg-neutral-dark p-4 rounded-md overflow-x-auto text-sm">
|
||||
{JSON.stringify(submittedData, null, 2)}
|
||||
</pre>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Examples Page Component
|
||||
export const FormExamplesPage: React.FC = () => {
|
||||
return (
|
||||
<Container className="py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-4xl font-bold">Form System Examples</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Comprehensive examples of all form components and patterns
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<ContactFormExample />
|
||||
<RegistrationFormExample />
|
||||
<SearchFormExample />
|
||||
<RadioFormExample />
|
||||
<CompleteFormExample />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormExamplesPage;
|
||||
@@ -1,218 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
import FormInput from './FormInput';
|
||||
import FormTextarea from './FormTextarea';
|
||||
import FormSelect from './FormSelect';
|
||||
import FormCheckbox from './FormCheckbox';
|
||||
import FormRadio from './FormRadio';
|
||||
|
||||
/**
|
||||
* FormField Component
|
||||
* Wrapper for form fields with label, input, and error
|
||||
* Supports different input types and provides consistent form experience
|
||||
*/
|
||||
|
||||
export type FormFieldType =
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'tel'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'checkbox'
|
||||
| 'radio'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'date'
|
||||
| 'time'
|
||||
| 'url';
|
||||
|
||||
export interface FormFieldProps {
|
||||
type?: FormFieldType;
|
||||
label?: string;
|
||||
name: string;
|
||||
value?: any;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
|
||||
// For select, checkbox, radio
|
||||
options?: any[];
|
||||
|
||||
// For select
|
||||
multiple?: boolean;
|
||||
showSearch?: boolean;
|
||||
|
||||
// For checkbox/radio
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
|
||||
// For textarea
|
||||
rows?: number;
|
||||
showCharCount?: boolean;
|
||||
autoResize?: boolean;
|
||||
maxLength?: number;
|
||||
|
||||
// For input
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
showClear?: boolean;
|
||||
iconPosition?: 'prefix' | 'suffix';
|
||||
|
||||
// Callbacks
|
||||
onChange?: (value: any) => void;
|
||||
onBlur?: () => void;
|
||||
onClear?: () => void;
|
||||
|
||||
// Additional props
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const FormField: React.FC<FormFieldProps> = ({
|
||||
type = 'text',
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
className,
|
||||
containerClassName,
|
||||
options = [],
|
||||
multiple = false,
|
||||
showSearch = false,
|
||||
layout = 'vertical',
|
||||
rows = 4,
|
||||
showCharCount = false,
|
||||
autoResize = false,
|
||||
maxLength,
|
||||
prefix,
|
||||
suffix,
|
||||
showClear = false,
|
||||
iconPosition = 'prefix',
|
||||
onChange,
|
||||
onBlur,
|
||||
onClear,
|
||||
...props
|
||||
}) => {
|
||||
const commonProps = {
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
required,
|
||||
placeholder,
|
||||
'aria-label': label,
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
switch (type) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<FormTextarea
|
||||
{...commonProps}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
rows={rows}
|
||||
showCharCount={showCharCount}
|
||||
autoResize={autoResize}
|
||||
maxLength={maxLength}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<FormSelect
|
||||
{...commonProps}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
options={options}
|
||||
multiple={multiple}
|
||||
showSearch={showSearch}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<FormCheckbox
|
||||
label={label}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
required={required}
|
||||
checked={Array.isArray(value) ? value.length > 0 : !!value}
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
containerClassName={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<FormRadio
|
||||
label={label}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
required={required}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
layout={layout}
|
||||
containerClassName={className}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<FormInput
|
||||
{...commonProps}
|
||||
type={type}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
label={label}
|
||||
prefix={prefix}
|
||||
suffix={suffix}
|
||||
showClear={showClear}
|
||||
iconPosition={iconPosition}
|
||||
onClear={onClear}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// For checkbox and radio, the label is handled internally
|
||||
const showExternalLabel = type !== 'checkbox' && type !== 'radio';
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1.5', containerClassName)}>
|
||||
{showExternalLabel && label && (
|
||||
<FormLabel htmlFor={name} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
{renderInput()}
|
||||
|
||||
{!showExternalLabel && error && (
|
||||
<FormError errors={error} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.displayName = 'FormField';
|
||||
|
||||
export default FormField;
|
||||
@@ -1,178 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormInput Component
|
||||
* Base input component with all HTML5 input types, validation states, icons, and clear button
|
||||
*/
|
||||
|
||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix' | 'suffix'> {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
showClear?: boolean;
|
||||
iconPosition?: 'prefix' | 'suffix';
|
||||
containerClassName?: string;
|
||||
inputClassName?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export const FormInput: React.FC<FormInputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
prefix,
|
||||
suffix,
|
||||
showClear = false,
|
||||
iconPosition = 'prefix',
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
onClear,
|
||||
disabled = false,
|
||||
value = '',
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (onChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: '', name: props.name, type: props.type },
|
||||
currentTarget: { value: '', name: props.name, type: props.type },
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(syntheticEvent);
|
||||
}
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
}, [onChange, onClear, props.name, props.type]);
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseInputClasses = cn(
|
||||
'w-full px-3 py-2 border rounded-md transition-all duration-200',
|
||||
'bg-neutral-light text-text-primary',
|
||||
'placeholder:text-text-light',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
{
|
||||
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
||||
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
||||
'border-danger ring-2 ring-danger/20': hasError,
|
||||
'pl-10': prefix && iconPosition === 'prefix',
|
||||
'pr-10': (suffix && iconPosition === 'suffix') || (showClear && value),
|
||||
},
|
||||
inputClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-1.5',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const iconWrapperClasses = cn(
|
||||
'absolute top-1/2 -translate-y-1/2 flex items-center pointer-events-none text-text-secondary',
|
||||
{
|
||||
'left-3': iconPosition === 'prefix',
|
||||
'right-3': iconPosition === 'suffix',
|
||||
}
|
||||
);
|
||||
|
||||
const clearButtonClasses = cn(
|
||||
'absolute top-1/2 -translate-y-1/2 right-2',
|
||||
'p-1 rounded-md hover:bg-neutral-dark transition-colors',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary'
|
||||
);
|
||||
|
||||
const showPrefix = prefix && iconPosition === 'prefix';
|
||||
const showSuffix = suffix && iconPosition === 'suffix';
|
||||
const showClearButton = showClear && value && !disabled;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{showPrefix && (
|
||||
<div className={iconWrapperClasses}>
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
className={baseInputClasses}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
||||
required={required}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{showSuffix && (
|
||||
<div className={cn(iconWrapperClasses, 'right-3 left-auto')}>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showClearButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={clearButtonClasses}
|
||||
aria-label="Clear input"
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormInput.displayName = 'FormInput';
|
||||
|
||||
export default FormInput;
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FormLabel Component
|
||||
* Consistent label styling with required indicator and help text tooltip
|
||||
*/
|
||||
|
||||
export interface FormLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
helpText?: string;
|
||||
optionalText?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormLabel: React.FC<FormLabelProps> = ({
|
||||
htmlFor,
|
||||
required = false,
|
||||
helpText,
|
||||
optionalText = '(optional)',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'block text-sm font-semibold text-text-primary mb-2',
|
||||
'font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{children}
|
||||
{required && (
|
||||
<span className="text-danger" aria-label="required">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{!required && optionalText && (
|
||||
<span className="text-xs text-text-secondary font-normal">
|
||||
{optionalText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{helpText && (
|
||||
<span className="ml-2 text-xs text-text-secondary font-normal">
|
||||
{helpText}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
export default FormLabel;
|
||||
@@ -1,192 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormRadio Component
|
||||
* Radio button group with custom styling and keyboard navigation
|
||||
*/
|
||||
|
||||
export interface RadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FormRadioProps {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
options: RadioOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
containerClassName?: string;
|
||||
radioClassName?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export const FormRadio: React.FC<FormRadioProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
containerClassName,
|
||||
radioClassName,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
layout = 'vertical',
|
||||
}) => {
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
const groupName = name || inputId;
|
||||
|
||||
const baseRadioClasses = cn(
|
||||
'w-4 h-4 border rounded-full transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary',
|
||||
{
|
||||
'border-neutral-dark bg-neutral-light': !hasError,
|
||||
'border-danger': hasError,
|
||||
'opacity-60 cursor-not-allowed': disabled,
|
||||
'cursor-pointer': !disabled,
|
||||
},
|
||||
radioClassName
|
||||
);
|
||||
|
||||
const selectedIndicatorClasses = cn(
|
||||
'w-2.5 h-2.5 rounded-full bg-primary transition-all duration-200',
|
||||
{
|
||||
'scale-0': false,
|
||||
'scale-100': true,
|
||||
}
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-2',
|
||||
{
|
||||
'gap-3': layout === 'vertical',
|
||||
'gap-4 flex-row flex-wrap': layout === 'horizontal',
|
||||
},
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const optionWrapperClasses = cn(
|
||||
'flex items-start gap-2',
|
||||
{
|
||||
'opacity-60': disabled,
|
||||
}
|
||||
);
|
||||
|
||||
const labelClasses = cn(
|
||||
'text-sm font-medium leading-none cursor-pointer',
|
||||
{
|
||||
'text-text-primary': !hasError,
|
||||
'text-danger': hasError,
|
||||
}
|
||||
);
|
||||
|
||||
const descriptionClasses = 'text-xs text-text-secondary mt-0.5';
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1.5', containerClassName)}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={containerClasses}
|
||||
role="radiogroup"
|
||||
aria-labelledby={inputId ? `${inputId}-label` : undefined}
|
||||
aria-invalid={hasError}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.value}`;
|
||||
const isChecked = value === option.value;
|
||||
|
||||
return (
|
||||
<div key={option.value} className={optionWrapperClasses}>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="radio"
|
||||
id={optionId}
|
||||
name={groupName}
|
||||
value={option.value}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled || option.disabled}
|
||||
required={required}
|
||||
aria-describedby={helpText || showError || option.description ? `${inputId}-error ${optionId}-desc` : undefined}
|
||||
className={cn(
|
||||
baseRadioClasses,
|
||||
option.disabled && 'opacity-50',
|
||||
isChecked && 'border-primary'
|
||||
)}
|
||||
/>
|
||||
|
||||
{isChecked && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className={selectedIndicatorClasses} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor={optionId}
|
||||
className={cn(
|
||||
labelClasses,
|
||||
option.disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
|
||||
{option.description && (
|
||||
<p
|
||||
className={descriptionClasses}
|
||||
id={`${optionId}-desc`}
|
||||
>
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormRadio.displayName = 'FormRadio';
|
||||
|
||||
export default FormRadio;
|
||||
@@ -1,200 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormSelect Component
|
||||
* Select dropdown with placeholder, multi-select support, and custom styling
|
||||
*/
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'multiple' | 'size'> {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
showSearch?: boolean;
|
||||
containerClassName?: string;
|
||||
selectClassName?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export const FormSelect: React.FC<FormSelectProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
options,
|
||||
placeholder = 'Select an option',
|
||||
multiple = false,
|
||||
showSearch = false,
|
||||
containerClassName,
|
||||
selectClassName,
|
||||
onSearch,
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange?.(e);
|
||||
}, [onChange]);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
if (onSearch) {
|
||||
onSearch(query);
|
||||
}
|
||||
}, [onSearch]);
|
||||
|
||||
const filteredOptions = showSearch && searchQuery
|
||||
? options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: options;
|
||||
|
||||
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseSelectClasses = cn(
|
||||
'w-full px-3 py-2 border rounded-md transition-all duration-200',
|
||||
'bg-neutral-light text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'appearance-none cursor-pointer',
|
||||
'bg-[length:1.5em_1.5em] bg-[position:right_0.5rem_center] bg-no-repeat',
|
||||
'bg-[url("data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 20 20\'%3e%3cpath stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'1.5\' d=\'M6 8l4 4 4-4\'/%3e%3c/svg%3e")]',
|
||||
{
|
||||
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
||||
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
||||
'border-danger ring-2 ring-danger/20': hasError,
|
||||
'pr-10': !showSearch,
|
||||
},
|
||||
selectClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-1.5',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const searchInputClasses = cn(
|
||||
'w-full px-3 py-2 border-b border-neutral-dark bg-transparent',
|
||||
'focus:outline-none focus:border-primary',
|
||||
'placeholder:text-text-light'
|
||||
);
|
||||
|
||||
// Custom dropdown arrow
|
||||
const dropdownArrow = (
|
||||
<svg
|
||||
className="w-4 h-4 text-text-secondary pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const renderOptions = () => {
|
||||
// Add placeholder as first option if not multiple and no value
|
||||
const showPlaceholder = !multiple && !value && placeholder;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPlaceholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{filteredOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={option.disabled ? 'opacity-50' : ''}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{showSearch && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search options..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={searchInputClasses}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<select
|
||||
id={inputId}
|
||||
className={baseSelectClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
multiple={multiple}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
||||
required={required}
|
||||
{...props}
|
||||
>
|
||||
{renderOptions()}
|
||||
</select>
|
||||
|
||||
{!showSearch && dropdownArrow}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormSelect.displayName = 'FormSelect';
|
||||
|
||||
export default FormSelect;
|
||||
@@ -1,132 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FormSuccess Component
|
||||
* Display success messages with different variants and auto-dismiss
|
||||
*/
|
||||
|
||||
export interface FormSuccessProps {
|
||||
message?: string;
|
||||
variant?: 'inline' | 'block' | 'toast';
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
animate?: boolean;
|
||||
autoDismiss?: boolean;
|
||||
autoDismissTimeout?: number;
|
||||
onClose?: () => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const FormSuccess: React.FC<FormSuccessProps> = ({
|
||||
message,
|
||||
variant = 'inline',
|
||||
className,
|
||||
showIcon = true,
|
||||
animate = true,
|
||||
autoDismiss = false,
|
||||
autoDismissTimeout = 5000,
|
||||
onClose,
|
||||
id,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVisible(true);
|
||||
|
||||
if (autoDismiss && autoDismissTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, autoDismissTimeout);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message, autoDismiss, autoDismissTimeout, onClose]);
|
||||
|
||||
if (!message || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseClasses = {
|
||||
inline: 'text-sm text-success mt-1',
|
||||
block: 'p-3 bg-success/10 border border-success/20 rounded-md text-success text-sm',
|
||||
toast: 'fixed bottom-4 right-4 p-4 bg-success text-white rounded-lg shadow-lg max-w-md z-tooltip animate-slide-up',
|
||||
};
|
||||
|
||||
const animationClasses = animate ? 'animate-fade-in' : '';
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
className="w-4 h-4 mr-1 inline-block"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
id={id}
|
||||
className={cn(
|
||||
baseClasses[variant],
|
||||
animationClasses,
|
||||
'transition-all duration-200',
|
||||
'flex items-start justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start flex-1">
|
||||
{showIcon && <Icon />}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
|
||||
{autoDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-current opacity-70 hover:opacity-100 transition-opacity"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormSuccess.displayName = 'FormSuccess';
|
||||
|
||||
export default FormSuccess;
|
||||
@@ -1,169 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormTextarea Component
|
||||
* Textarea with resize options, character counter, auto-resize, and validation states
|
||||
*/
|
||||
|
||||
export interface FormTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength'> {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
showCharCount?: boolean;
|
||||
autoResize?: boolean;
|
||||
maxHeight?: number;
|
||||
minHeight?: number;
|
||||
containerClassName?: string;
|
||||
textareaClassName?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const FormTextarea: React.FC<FormTextareaProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
showCharCount = false,
|
||||
autoResize = false,
|
||||
maxHeight = 300,
|
||||
minHeight = 120,
|
||||
containerClassName,
|
||||
textareaClassName,
|
||||
maxLength,
|
||||
disabled = false,
|
||||
value = '',
|
||||
onChange,
|
||||
rows = 4,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
// Update character count
|
||||
useEffect(() => {
|
||||
const currentValue = typeof value === 'string' ? value : String(value || '');
|
||||
setCharCount(currentValue.length);
|
||||
}, [value]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (!autoResize || !textareaRef.current) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
// Reset height to calculate new height
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Calculate new height
|
||||
const newHeight = Math.min(
|
||||
Math.max(textarea.scrollHeight, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [value, autoResize, minHeight, maxHeight]);
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (maxLength && e.target.value.length > maxLength) {
|
||||
e.target.value = e.target.value.slice(0, maxLength);
|
||||
}
|
||||
onChange?.(e);
|
||||
}, [onChange, maxLength]);
|
||||
|
||||
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseTextareaClasses = cn(
|
||||
'w-full px-3 py-2 border rounded-md transition-all duration-200 resize-y',
|
||||
'bg-neutral-light text-text-primary',
|
||||
'placeholder:text-text-light',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
{
|
||||
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
||||
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
||||
'border-danger ring-2 ring-danger/20': hasError,
|
||||
},
|
||||
autoResize && 'overflow-hidden',
|
||||
textareaClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-1.5',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const charCountClasses = cn(
|
||||
'text-xs text-right mt-1',
|
||||
{
|
||||
'text-text-secondary': charCount <= (maxLength || 0) * 0.8,
|
||||
'text-warning': charCount > (maxLength || 0) * 0.8 && charCount <= (maxLength || 0),
|
||||
'text-danger': charCount > (maxLength || 0),
|
||||
}
|
||||
);
|
||||
|
||||
const showCharCounter = showCharCount || (maxLength && charCount > 0);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id={inputId}
|
||||
className={baseTextareaClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError || showCharCounter ? `${inputId}-error ${inputId}-help ${inputId}-count` : undefined}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
style={autoResize ? { minHeight: `${minHeight}px`, overflow: 'hidden' } : {}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary flex-1" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCharCounter && (
|
||||
<p className={charCountClasses} id={`${inputId}-count`}>
|
||||
{charCount}
|
||||
{maxLength ? ` / ${maxLength}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormTextarea.displayName = 'FormTextarea';
|
||||
|
||||
export default FormTextarea;
|
||||
@@ -1,632 +0,0 @@
|
||||
# KLZ Forms System
|
||||
|
||||
A comprehensive, reusable form system for Next.js applications with full TypeScript support, accessibility features, and consistent styling.
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete Form Components**: All essential form inputs with consistent styling
|
||||
- **Validation System**: Built-in validation with custom rules
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Accessibility**: ARIA attributes and keyboard navigation
|
||||
- **Internationalization**: Ready for i18n
|
||||
- **Customizable**: Flexible props for different use cases
|
||||
- **Animation**: Smooth transitions and animations
|
||||
- **Error Handling**: Multiple error display modes
|
||||
- **Auto-resize**: Smart textarea resizing
|
||||
- **Character Count**: Built-in character counting
|
||||
|
||||
## Installation
|
||||
|
||||
The form system is already included in the project. All components use the existing design system tokens.
|
||||
|
||||
## Components
|
||||
|
||||
### FormField
|
||||
Wrapper component that provides consistent form field experience.
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
type="text"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
value={value}
|
||||
error={error}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Supported Types**: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `radio`, `number`, `password`, `date`, `time`, `url`
|
||||
|
||||
### FormInput
|
||||
Base input component with icon support and clear button.
|
||||
|
||||
```tsx
|
||||
<FormInput
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
prefix={<EmailIcon />}
|
||||
showClear
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormTextarea
|
||||
Textarea with auto-resize and character counting.
|
||||
|
||||
```tsx
|
||||
<FormTextarea
|
||||
name="message"
|
||||
label="Message"
|
||||
rows={5}
|
||||
showCharCount
|
||||
maxLength={500}
|
||||
autoResize
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormSelect
|
||||
Select dropdown with search and multi-select support.
|
||||
|
||||
```tsx
|
||||
<FormSelect
|
||||
name="country"
|
||||
label="Country"
|
||||
options={[
|
||||
{ value: 'de', label: 'Germany' },
|
||||
{ value: 'us', label: 'United States' }
|
||||
]}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormCheckbox
|
||||
Single checkbox or checkbox group with indeterminate state.
|
||||
|
||||
```tsx
|
||||
// Single checkbox
|
||||
<FormCheckbox
|
||||
name="agree"
|
||||
label="I agree to terms"
|
||||
checked={checked}
|
||||
onChange={(values) => setChecked(values.length > 0)}
|
||||
/>
|
||||
|
||||
// Checkbox group
|
||||
<FormCheckbox
|
||||
name="services"
|
||||
label="Services"
|
||||
options={[
|
||||
{ value: 'consulting', label: 'Consulting' },
|
||||
{ value: 'support', label: 'Support' }
|
||||
]}
|
||||
value={selectedValues}
|
||||
onChange={(values) => setSelectedValues(values)}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormRadio
|
||||
Radio button group with custom styling.
|
||||
|
||||
```tsx
|
||||
<FormRadio
|
||||
name="payment"
|
||||
label="Payment Method"
|
||||
options={[
|
||||
{ value: 'credit-card', label: 'Credit Card' },
|
||||
{ value: 'paypal', label: 'PayPal' }
|
||||
]}
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormError
|
||||
Error message display with multiple variants.
|
||||
|
||||
```tsx
|
||||
<FormError
|
||||
errors={errors}
|
||||
variant="block"
|
||||
showIcon
|
||||
/>
|
||||
```
|
||||
|
||||
### FormSuccess
|
||||
Success message with auto-dismiss option.
|
||||
|
||||
```tsx
|
||||
<FormSuccess
|
||||
message="Form submitted successfully!"
|
||||
autoDismiss
|
||||
onClose={() => setShowSuccess(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useForm
|
||||
Main form state management hook with validation and submission handling.
|
||||
|
||||
```tsx
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
validationRules: {
|
||||
name: { required: true, minLength: { value: 2, message: 'Too short' } },
|
||||
email: { required: true, email: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
// Handle submission
|
||||
await api.submit(values);
|
||||
},
|
||||
});
|
||||
|
||||
// In your component
|
||||
<form {...form.getFormProps()}>
|
||||
<input
|
||||
value={form.values.name}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
/>
|
||||
{form.errors.name && <FormError errors={form.errors.name} />}
|
||||
<button type="submit" disabled={!form.isValid || form.isSubmitting}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### useFormField
|
||||
Hook for managing individual field state.
|
||||
|
||||
```tsx
|
||||
const field = useFormField({
|
||||
initialValue: '',
|
||||
validate: (value) => value.length < 2 ? 'Too short' : null,
|
||||
});
|
||||
|
||||
// field.value, field.error, field.touched, field.handleChange, etc.
|
||||
```
|
||||
|
||||
### useFormValidation
|
||||
Validation logic and utilities.
|
||||
|
||||
```tsx
|
||||
const { validateField, validateForm } = useFormValidation();
|
||||
|
||||
const errors = validateField(value, {
|
||||
required: true,
|
||||
email: true,
|
||||
}, 'email');
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Available validation rules:
|
||||
|
||||
```typescript
|
||||
{
|
||||
required: boolean | string; // Required field
|
||||
minLength: { value: number, message: string };
|
||||
maxLength: { value: number, message: string };
|
||||
pattern: { value: RegExp, message: string };
|
||||
min: { value: number, message: string };
|
||||
max: { value: number, message: string };
|
||||
email: boolean | string;
|
||||
url: boolean | string;
|
||||
number: boolean | string;
|
||||
custom: (value) => string | null; // Custom validation
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Contact Form
|
||||
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
export function ContactForm() {
|
||||
const form = useForm({
|
||||
initialValues: { name: '', email: '', message: '' },
|
||||
validationRules: {
|
||||
name: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
message: { required: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await sendEmail(values);
|
||||
alert('Sent!');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<FormField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
value={form.values.name}
|
||||
error={form.errors.name?.[0]}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
type="textarea"
|
||||
name="message"
|
||||
label="Message"
|
||||
required
|
||||
rows={5}
|
||||
value={form.values.message}
|
||||
error={form.errors.message?.[0]}
|
||||
onChange={(e) => form.setFieldValue('message', e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!form.isValid || form.isSubmitting}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Registration Form
|
||||
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
export function RegistrationForm() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
terms: false,
|
||||
},
|
||||
validationRules: {
|
||||
firstName: { required: true, minLength: { value: 2 } },
|
||||
lastName: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
password: {
|
||||
required: true,
|
||||
minLength: { value: 8 },
|
||||
pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/ }
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
custom: (value) => value === form.values.password ? null : 'Passwords do not match'
|
||||
},
|
||||
terms: {
|
||||
required: 'You must accept terms',
|
||||
custom: (value) => value ? null : 'Required'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await registerUser(values);
|
||||
alert('Registered!');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
required
|
||||
value={form.values.firstName}
|
||||
error={form.errors.firstName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('firstName', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
required
|
||||
value={form.values.lastName}
|
||||
error={form.errors.lastName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('lastName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
required
|
||||
helpText="Min 8 chars with uppercase, lowercase, and number"
|
||||
value={form.values.password}
|
||||
error={form.errors.password?.[0]}
|
||||
onChange={(e) => form.setFieldValue('password', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
required
|
||||
value={form.values.confirmPassword}
|
||||
error={form.errors.confirmPassword?.[0]}
|
||||
onChange={(e) => form.setFieldValue('confirmPassword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="terms"
|
||||
label="I accept the terms and conditions"
|
||||
required
|
||||
checked={form.values.terms}
|
||||
error={form.errors.terms?.[0]}
|
||||
onChange={(values) => form.setFieldValue('terms', values.length > 0)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!form.isValid || form.isSubmitting}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Search and Filter Form
|
||||
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
export function SearchForm() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
search: '',
|
||||
category: '',
|
||||
status: '',
|
||||
},
|
||||
validationRules: {},
|
||||
onSubmit: async (values) => {
|
||||
await performSearch(values);
|
||||
},
|
||||
});
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'cables', label: 'Cables' },
|
||||
{ value: 'connectors', label: 'Connectors' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
label="Search"
|
||||
placeholder="Search..."
|
||||
prefix={<SearchIcon />}
|
||||
showClear
|
||||
value={form.values.search}
|
||||
onChange={(e) => form.setFieldValue('search', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
options={categoryOptions}
|
||||
value={form.values.category}
|
||||
onChange={(e) => form.setFieldValue('category', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 items-end">
|
||||
<Button type="submit" variant="primary" size="sm">
|
||||
Search
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={form.reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use FormField for Consistency
|
||||
```tsx
|
||||
// ✅ Good
|
||||
<FormField name="email" type="email" label="Email" ... />
|
||||
|
||||
// ❌ Avoid
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<input type="email" ... />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Validate Before Submit
|
||||
```tsx
|
||||
const form = useForm({
|
||||
validationRules: {
|
||||
email: { required: true, email: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
// Validation happens automatically
|
||||
// Only called if isValid is true
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Show Errors Only After Touch
|
||||
```tsx
|
||||
{form.touched.email && form.errors.email && (
|
||||
<FormError errors={form.errors.email} />
|
||||
)}
|
||||
```
|
||||
|
||||
### 4. Disable Submit When Invalid
|
||||
```tsx
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.isValid || form.isSubmitting}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 5. Reset After Success
|
||||
```tsx
|
||||
onSubmit: async (values) => {
|
||||
await submit(values);
|
||||
form.reset();
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
All components include:
|
||||
- Proper ARIA attributes
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Screen reader support
|
||||
- Required field indicators
|
||||
|
||||
## Styling
|
||||
|
||||
Components use the design system:
|
||||
- Colors: `--color-primary`, `--color-danger`, `--color-success`
|
||||
- Spacing: `--spacing-sm`, `--spacing-md`, etc.
|
||||
- Typography: `--font-size-sm`, `--font-size-base`
|
||||
- Borders: `--radius-md`
|
||||
- Transitions: `--transition-fast`
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full TypeScript support with proper interfaces:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
FormFieldProps,
|
||||
FormInputProps,
|
||||
ValidationRules,
|
||||
FormErrors
|
||||
} from '@/components/forms';
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Example test setup:
|
||||
|
||||
```tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useForm } from '@/components/forms';
|
||||
|
||||
test('form validation', () => {
|
||||
const TestComponent = () => {
|
||||
const form = useForm({
|
||||
initialValues: { email: '' },
|
||||
validationRules: { email: { required: true, email: true } },
|
||||
onSubmit: jest.fn(),
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<input
|
||||
value={form.values.email}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
// Validation should trigger
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Memoize validation rules** if they depend on external values
|
||||
2. **Use useCallback** for event handlers
|
||||
3. **Avoid unnecessary re-renders** by splitting large forms
|
||||
4. **Lazy load** form examples for better initial load
|
||||
|
||||
## Migration from Legacy Forms
|
||||
|
||||
If migrating from old form components:
|
||||
|
||||
```tsx
|
||||
// Old
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className={error ? 'error' : ''}
|
||||
/>
|
||||
|
||||
// New
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Validation not working**: Ensure `validationRules` match `initialValues` keys
|
||||
2. **Form not submitting**: Check `isValid` state and `required` fields
|
||||
3. **Type errors**: Import proper types from the forms module
|
||||
4. **Styling issues**: Ensure design system CSS is imported
|
||||
|
||||
### Getting Help
|
||||
|
||||
Check the examples in `FormExamples.tsx` for complete implementations.
|
||||
|
||||
## License
|
||||
|
||||
Internal KLZ Cables component system
|
||||
@@ -1,275 +0,0 @@
|
||||
import { useState, useCallback, FormEvent } from 'react';
|
||||
import { useFormValidation, ValidationRules, FormErrors } from './useFormValidation';
|
||||
|
||||
/**
|
||||
* Hook for managing complete form state and submission
|
||||
*/
|
||||
|
||||
export interface FormState<T extends Record<string, any>> {
|
||||
values: T;
|
||||
errors: FormErrors;
|
||||
touched: Record<keyof T, boolean>;
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
isSubmitted: boolean;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export interface FormOptions<T extends Record<string, any>> {
|
||||
initialValues: T;
|
||||
validationRules: Record<keyof T, ValidationRules>;
|
||||
onSubmit: (values: T) => Promise<void> | void;
|
||||
validateOnMount?: boolean;
|
||||
}
|
||||
|
||||
export interface FormReturn<T extends Record<string, any>> extends FormState<T> {
|
||||
setFieldValue: (field: keyof T, value: any) => void;
|
||||
setFieldError: (field: keyof T, error: string) => void;
|
||||
clearFieldError: (field: keyof T) => void;
|
||||
handleChange: (field: keyof T, value: any) => void;
|
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>;
|
||||
reset: () => void;
|
||||
setAllTouched: () => void;
|
||||
setValues: (values: T) => void;
|
||||
setErrors: (errors: FormErrors) => void;
|
||||
setSubmitting: (isSubmitting: boolean) => void;
|
||||
getFormProps: () => { onSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>; noValidate: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing complete form state with validation and submission
|
||||
*/
|
||||
export function useForm<T extends Record<string, any>>(
|
||||
options: FormOptions<T>
|
||||
): FormReturn<T> {
|
||||
const {
|
||||
initialValues,
|
||||
validationRules,
|
||||
onSubmit,
|
||||
validateOnMount = false,
|
||||
} = options;
|
||||
|
||||
const {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
setFieldValue: validationSetFieldValue,
|
||||
setFieldError: validationSetFieldError,
|
||||
clearFieldError: validationClearFieldError,
|
||||
validate,
|
||||
reset: validationReset,
|
||||
setAllTouched: validationSetAllTouched,
|
||||
setValues: validationSetValues,
|
||||
} = useFormValidation<T>(initialValues, validationRules);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [submitCount, setSubmitCount] = useState(0);
|
||||
|
||||
// Validate on mount if requested
|
||||
// Note: This is handled by useFormValidation's useEffect
|
||||
|
||||
const setFieldValue = useCallback((field: keyof T, value: any) => {
|
||||
validationSetFieldValue(field, value);
|
||||
}, [validationSetFieldValue]);
|
||||
|
||||
const setFieldError = useCallback((field: keyof T, error: string) => {
|
||||
validationSetFieldError(field, error);
|
||||
}, [validationSetFieldError]);
|
||||
|
||||
const clearFieldError = useCallback((field: keyof T) => {
|
||||
validationClearFieldError(field);
|
||||
}, [validationClearFieldError]);
|
||||
|
||||
const handleChange = useCallback((field: keyof T, value: any) => {
|
||||
setFieldValue(field, value);
|
||||
}, [setFieldValue]);
|
||||
|
||||
const setErrors = useCallback((newErrors: FormErrors) => {
|
||||
Object.entries(newErrors).forEach(([field, fieldErrors]) => {
|
||||
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
||||
fieldErrors.forEach((error) => {
|
||||
setFieldError(field as keyof T, error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [setFieldError]);
|
||||
|
||||
const setSubmitting = useCallback((state: boolean) => {
|
||||
setIsSubmitting(state);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
validationReset();
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(false);
|
||||
setSubmitCount(0);
|
||||
}, [validationReset]);
|
||||
|
||||
const setAllTouched = useCallback(() => {
|
||||
validationSetAllTouched();
|
||||
}, [validationSetAllTouched]);
|
||||
|
||||
const setValues = useCallback((newValues: T) => {
|
||||
validationSetValues(newValues);
|
||||
}, [validationSetValues]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Increment submit count
|
||||
setSubmitCount((prev) => prev + 1);
|
||||
|
||||
// Set all fields as touched to show validation errors
|
||||
setAllTouched();
|
||||
|
||||
// Validate form
|
||||
const validation = validate();
|
||||
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start submission
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Call submit handler
|
||||
await onSubmit(values);
|
||||
setIsSubmitted(true);
|
||||
} catch (error) {
|
||||
// Handle submission error
|
||||
console.error('Form submission error:', error);
|
||||
|
||||
// You can set a general error or handle specific error cases
|
||||
if (error instanceof Error) {
|
||||
setFieldError('submit' as keyof T, error.message);
|
||||
} else {
|
||||
setFieldError('submit' as keyof T, 'An error occurred during submission');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [values, onSubmit, validate, setAllTouched, setFieldError]);
|
||||
|
||||
const getFormProps = useCallback(() => ({
|
||||
onSubmit: handleSubmit,
|
||||
noValidate: true,
|
||||
}), [handleSubmit]);
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
isSubmitted,
|
||||
submitCount,
|
||||
setFieldValue,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setAllTouched,
|
||||
setValues,
|
||||
setErrors,
|
||||
setSubmitting,
|
||||
getFormProps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing form state with additional utilities
|
||||
*/
|
||||
export function useFormWithHelpers<T extends Record<string, any>>(
|
||||
options: FormOptions<T>
|
||||
) {
|
||||
const form = useForm<T>(options);
|
||||
|
||||
const getFormProps = () => ({
|
||||
onSubmit: form.handleSubmit,
|
||||
noValidate: true, // We handle validation manually
|
||||
});
|
||||
|
||||
const getSubmitButtonProps = () => ({
|
||||
type: 'submit',
|
||||
disabled: form.isSubmitting || !form.isValid,
|
||||
loading: form.isSubmitting,
|
||||
});
|
||||
|
||||
const getResetButtonProps = () => ({
|
||||
type: 'button',
|
||||
onClick: form.reset,
|
||||
disabled: form.isSubmitting,
|
||||
});
|
||||
|
||||
const getFieldProps = (field: keyof T) => ({
|
||||
value: form.values[field] as any,
|
||||
onChange: (e: any) => {
|
||||
const target = e.target;
|
||||
let value: any = target.value;
|
||||
|
||||
if (target.type === 'checkbox') {
|
||||
value = target.checked;
|
||||
} else if (target.type === 'number') {
|
||||
value = target.value === '' ? '' : Number(target.value);
|
||||
}
|
||||
|
||||
form.setFieldValue(field, value);
|
||||
},
|
||||
error: form.errors[field as string]?.[0],
|
||||
touched: form.touched[field],
|
||||
onBlur: () => {
|
||||
// Mark as touched on blur if not already
|
||||
if (!form.touched[field]) {
|
||||
form.setAllTouched();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hasFieldError = (field: keyof T): boolean => {
|
||||
return !!form.errors[field as string]?.length && !!form.touched[field];
|
||||
};
|
||||
|
||||
const getFieldError = (field: keyof T): string | null => {
|
||||
const errors = form.errors[field as string];
|
||||
return errors && errors.length > 0 ? errors[0] : null;
|
||||
};
|
||||
|
||||
const clearFieldError = (field: keyof T) => {
|
||||
form.clearFieldError(field);
|
||||
};
|
||||
|
||||
const setFieldError = (field: keyof T, error: string) => {
|
||||
form.setFieldError(field, error);
|
||||
};
|
||||
|
||||
const isDirty = (): boolean => {
|
||||
return Object.keys(form.values).some((key) => {
|
||||
const currentValue = form.values[key as keyof T];
|
||||
const initialValue = options.initialValues[key as keyof T];
|
||||
return currentValue !== initialValue;
|
||||
});
|
||||
};
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
return !form.isSubmitting && form.isValid && isDirty();
|
||||
};
|
||||
|
||||
return {
|
||||
...form,
|
||||
getFormProps,
|
||||
getSubmitButtonProps,
|
||||
getResetButtonProps,
|
||||
getFieldProps,
|
||||
hasFieldError,
|
||||
getFieldError,
|
||||
clearFieldError,
|
||||
setFieldError,
|
||||
isDirty,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { useState, useCallback, ChangeEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for managing individual form field state
|
||||
*/
|
||||
|
||||
export interface FormFieldState<T> {
|
||||
value: T;
|
||||
error: string | null;
|
||||
touched: boolean;
|
||||
dirty: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export interface FormFieldOptions<T> {
|
||||
initialValue?: T;
|
||||
validate?: (value: T) => string | null;
|
||||
transform?: (value: T) => T;
|
||||
}
|
||||
|
||||
export interface FormFieldReturn<T> {
|
||||
value: T;
|
||||
error: string | null;
|
||||
touched: boolean;
|
||||
dirty: boolean;
|
||||
isValid: boolean;
|
||||
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
|
||||
setValue: (value: T) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setTouched: (touched: boolean) => void;
|
||||
reset: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing individual form field state with validation
|
||||
*/
|
||||
export function useFormField<T = string>(
|
||||
options: FormFieldOptions<T> = {}
|
||||
): FormFieldReturn<T> {
|
||||
const {
|
||||
initialValue = '' as unknown as T,
|
||||
validate,
|
||||
transform,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<FormFieldState<T>>({
|
||||
value: initialValue,
|
||||
error: null,
|
||||
touched: false,
|
||||
dirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const validateValue = useCallback((value: T): string | null => {
|
||||
if (validate) {
|
||||
return validate(value);
|
||||
}
|
||||
return null;
|
||||
}, [validate]);
|
||||
|
||||
const updateState = useCallback((newState: Partial<FormFieldState<T>>) => {
|
||||
setState((prev) => {
|
||||
const updated = { ...prev, ...newState };
|
||||
|
||||
// Auto-validate if value changes and validation is provided
|
||||
if ('value' in newState && validate) {
|
||||
const error = validateValue(newState.value as T);
|
||||
updated.error = error;
|
||||
updated.isValid = !error;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [validate, validateValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
let value: any = e.target.value;
|
||||
|
||||
// Handle different input types
|
||||
if (e.target.type === 'checkbox') {
|
||||
value = (e.target as HTMLInputElement).checked;
|
||||
} else if (e.target.type === 'number') {
|
||||
value = e.target.value === '' ? '' : Number(e.target.value);
|
||||
}
|
||||
|
||||
// Apply transformation if provided
|
||||
if (transform) {
|
||||
value = transform(value);
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
value,
|
||||
dirty: true,
|
||||
touched: true,
|
||||
}));
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
const setValue = useCallback((value: T) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
value,
|
||||
dirty: true,
|
||||
touched: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error,
|
||||
isValid: !error,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTouched = useCallback((touched: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
touched,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: null,
|
||||
isValid: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
value: initialValue,
|
||||
error: null,
|
||||
touched: false,
|
||||
dirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
}, [initialValue]);
|
||||
|
||||
// Auto-validate on mount if initial value exists
|
||||
// This ensures initial values are validated
|
||||
// Note: We're intentionally not adding initialValue to dependencies
|
||||
// to avoid infinite loops, but we validate once on mount
|
||||
// This is handled by the updateState function when value changes
|
||||
|
||||
return {
|
||||
value: state.value,
|
||||
error: state.error,
|
||||
touched: state.touched,
|
||||
dirty: state.dirty,
|
||||
isValid: state.isValid,
|
||||
handleChange,
|
||||
setValue,
|
||||
setError,
|
||||
setTouched,
|
||||
reset,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing form field state with additional utilities
|
||||
*/
|
||||
export function useFormFieldWithHelpers<T = string>(
|
||||
options: FormFieldOptions<T> & {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
helpText?: string;
|
||||
} = {}
|
||||
) {
|
||||
const field = useFormField<T>(options);
|
||||
|
||||
const hasError = field.error !== null;
|
||||
const showError = field.touched && hasError;
|
||||
const showSuccess = field.touched && !hasError && field.dirty;
|
||||
|
||||
const getAriaDescribedBy = () => {
|
||||
const descriptions: string[] = [];
|
||||
if (options.helpText) descriptions.push(`${options.label || 'field'}-help`);
|
||||
if (field.error) descriptions.push(`${options.label || 'field'}-error`);
|
||||
return descriptions.length > 0 ? descriptions.join(' ') : undefined;
|
||||
};
|
||||
|
||||
const getInputProps = () => ({
|
||||
value: field.value as any,
|
||||
onChange: field.handleChange,
|
||||
'aria-invalid': hasError,
|
||||
'aria-describedby': getAriaDescribedBy(),
|
||||
'aria-required': options.required,
|
||||
});
|
||||
|
||||
const getLabelProps = () => ({
|
||||
htmlFor: options.label?.toLowerCase().replace(/\s+/g, '-'),
|
||||
required: options.required,
|
||||
});
|
||||
|
||||
return {
|
||||
...field,
|
||||
hasError,
|
||||
showError,
|
||||
showSuccess,
|
||||
getInputProps,
|
||||
getLabelProps,
|
||||
getAriaDescribedBy,
|
||||
};
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Form Validation Hooks
|
||||
* Provides validation logic and utilities for form components
|
||||
*/
|
||||
|
||||
export interface ValidationRule {
|
||||
value: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationRules {
|
||||
required?: boolean | string;
|
||||
minLength?: ValidationRule;
|
||||
maxLength?: ValidationRule;
|
||||
pattern?: ValidationRule;
|
||||
min?: ValidationRule;
|
||||
max?: ValidationRule;
|
||||
email?: boolean | string;
|
||||
url?: boolean | string;
|
||||
number?: boolean | string;
|
||||
custom?: (value: any) => string | null;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FormErrors {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single field value against validation rules
|
||||
*/
|
||||
export function validateField(
|
||||
value: any,
|
||||
rules: ValidationRules,
|
||||
fieldName: string
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required validation
|
||||
if (rules.required) {
|
||||
const requiredMessage = typeof rules.required === 'string'
|
||||
? rules.required
|
||||
: `${fieldName} is required`;
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
errors.push(requiredMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Only validate other rules if there's a value (unless required)
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Min length validation
|
||||
if (rules.minLength) {
|
||||
const min = rules.minLength.value;
|
||||
const message = rules.minLength.message || `${fieldName} must be at least ${min} characters`;
|
||||
|
||||
if (typeof value === 'string' && value.length < min) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Max length validation
|
||||
if (rules.maxLength) {
|
||||
const max = rules.maxLength.value;
|
||||
const message = rules.maxLength.message || `${fieldName} must be at most ${max} characters`;
|
||||
|
||||
if (typeof value === 'string' && value.length > max) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (rules.pattern) {
|
||||
const pattern = rules.pattern.value;
|
||||
const message = rules.pattern.message || `${fieldName} format is invalid`;
|
||||
|
||||
if (typeof value === 'string' && !pattern.test(value)) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Min value validation
|
||||
if (rules.min) {
|
||||
const min = rules.min.value;
|
||||
const message = rules.min.message || `${fieldName} must be at least ${min}`;
|
||||
|
||||
if (typeof value === 'number' && value < min) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Max value validation
|
||||
if (rules.max) {
|
||||
const max = rules.max.value;
|
||||
const message = rules.max.message || `${fieldName} must be at most ${max}`;
|
||||
|
||||
if (typeof value === 'number' && value > max) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (rules.email) {
|
||||
const message = typeof rules.email === 'string'
|
||||
? rules.email
|
||||
: 'Please enter a valid email address';
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (typeof value === 'string' && !emailRegex.test(value)) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// URL validation
|
||||
if (rules.url) {
|
||||
const message = typeof rules.url === 'string'
|
||||
? rules.url
|
||||
: 'Please enter a valid URL';
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Number validation
|
||||
if (rules.number) {
|
||||
const message = typeof rules.number === 'string'
|
||||
? rules.number
|
||||
: 'Please enter a valid number';
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.custom) {
|
||||
const customError = rules.custom(value);
|
||||
if (customError) {
|
||||
errors.push(customError);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an entire form against validation rules
|
||||
*/
|
||||
export function validateForm<T extends Record<string, any>>(
|
||||
values: T,
|
||||
validationRules: Record<keyof T, ValidationRules>
|
||||
): { isValid: boolean; errors: FormErrors } {
|
||||
const errors: FormErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
Object.keys(validationRules).forEach((fieldName) => {
|
||||
const fieldRules = validationRules[fieldName as keyof T];
|
||||
const fieldValue = values[fieldName];
|
||||
const fieldErrors = validateField(fieldValue, fieldRules, fieldName);
|
||||
|
||||
if (fieldErrors.length > 0) {
|
||||
errors[fieldName] = fieldErrors;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for form validation
|
||||
*/
|
||||
export function useFormValidation<T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
validationRules: Record<keyof T, ValidationRules>
|
||||
) {
|
||||
const [values, setValues] = useState<T>(initialValues);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [touched, setTouched] = useState<Record<keyof T, boolean>>(
|
||||
Object.keys(initialValues).reduce((acc, key) => {
|
||||
acc[key as keyof T] = false;
|
||||
return acc;
|
||||
}, {} as Record<keyof T, boolean>)
|
||||
);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const validation = validateForm(values, validationRules);
|
||||
setErrors(validation.errors);
|
||||
setIsValid(validation.isValid);
|
||||
return validation;
|
||||
}, [values, validationRules]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
}, [validate]);
|
||||
|
||||
const setFieldValue = (field: keyof T, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||
};
|
||||
|
||||
const setFieldError = (field: keyof T, error: string) => {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: [...(prev[field as string] || []), error],
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFieldError = (field: keyof T) => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field as string];
|
||||
return newErrors;
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setValues(initialValues);
|
||||
setErrors({});
|
||||
setTouched(
|
||||
Object.keys(initialValues).reduce((acc, key) => {
|
||||
acc[key as keyof T] = false;
|
||||
return acc;
|
||||
}, {} as Record<keyof T, boolean>)
|
||||
);
|
||||
setIsValid(false);
|
||||
};
|
||||
|
||||
const setAllTouched = () => {
|
||||
setTouched(
|
||||
Object.keys(values).reduce((acc, key) => {
|
||||
acc[key as keyof T] = true;
|
||||
return acc;
|
||||
}, {} as Record<keyof T, boolean>)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
validate,
|
||||
reset,
|
||||
setAllTouched,
|
||||
setValues,
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* KLZ Forms System
|
||||
* Comprehensive form components and hooks for consistent form experience
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { FormField, type FormFieldProps, type FormFieldType } from './FormField';
|
||||
export { FormLabel, type FormLabelProps } from './FormLabel';
|
||||
export { FormInput, type FormInputProps } from './FormInput';
|
||||
export { FormTextarea, type FormTextareaProps } from './FormTextarea';
|
||||
export { FormSelect, type FormSelectProps } from './FormSelect';
|
||||
export { FormCheckbox, type FormCheckboxProps, type CheckboxOption } from './FormCheckbox';
|
||||
export { FormRadio, type FormRadioProps, type RadioOption } from './FormRadio';
|
||||
export { FormError, type FormErrorProps } from './FormError';
|
||||
export { FormSuccess, type FormSuccessProps } from './FormSuccess';
|
||||
|
||||
// Hooks
|
||||
export { useForm, useFormWithHelpers } from './hooks/useForm';
|
||||
export { useFormField, useFormFieldWithHelpers } from './hooks/useFormField';
|
||||
export {
|
||||
useFormValidation,
|
||||
validateField,
|
||||
validateForm,
|
||||
type ValidationRules,
|
||||
type ValidationRule,
|
||||
type ValidationError,
|
||||
type FormErrors
|
||||
} from './hooks/useFormValidation';
|
||||
|
||||
// Types
|
||||
export type FormValues = Record<string, any>;
|
||||
export type FormValidationRules = Record<string, any>;
|
||||
|
||||
// Re-export for convenience
|
||||
export * from './FormField';
|
||||
export * from './FormLabel';
|
||||
export * from './FormInput';
|
||||
export * from './FormTextarea';
|
||||
export * from './FormSelect';
|
||||
export * from './FormCheckbox';
|
||||
export * from './FormRadio';
|
||||
export * from './FormError';
|
||||
export * from './FormSuccess';
|
||||
export * from './hooks/useForm';
|
||||
export * from './hooks/useFormField';
|
||||
export * from './hooks/useFormValidation';
|
||||
@@ -1,163 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { Navigation } from './Navigation';
|
||||
|
||||
interface FooterProps {
|
||||
locale: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export function Footer({ locale, siteName = 'KLZ Cables' }: FooterProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Quick links
|
||||
const quickLinks = [
|
||||
{ title: 'About Us', path: `/${locale}/about` },
|
||||
{ title: 'Blog', path: `/${locale}/blog` },
|
||||
{ title: 'Products', path: `/${locale}/products` },
|
||||
{ title: 'Contact', path: `/${locale}/contact` }
|
||||
];
|
||||
|
||||
// Product categories
|
||||
const productCategories = [
|
||||
{ title: 'Medium Voltage Cables', path: `/${locale}/product-category/medium-voltage` },
|
||||
{ title: 'Low Voltage Cables', path: `/${locale}/product-category/low-voltage` },
|
||||
{ title: 'Cable Accessories', path: `/${locale}/product-category/accessories` },
|
||||
{ title: 'Special Solutions', path: `/${locale}/product-category/special` }
|
||||
];
|
||||
|
||||
// Legal links
|
||||
const legalLinks = [
|
||||
{ title: 'Privacy Policy', path: `/${locale}/privacy` },
|
||||
{ title: 'Terms of Service', path: `/${locale}/terms` },
|
||||
{ title: 'Imprint', path: `/${locale}/imprint` }
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-900 text-gray-300 border-t border-gray-800">
|
||||
<Container maxWidth="6xl" padding="lg">
|
||||
{/* Main Footer Content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
|
||||
{/* Company Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">KLZ</span>
|
||||
</div>
|
||||
<span className="font-bold text-white text-lg">{siteName}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-gray-400">
|
||||
Professional cable solutions for industrial applications.
|
||||
Quality, reliability, and innovation since 1990.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{/* Social Media Links */}
|
||||
<a href="#" className="text-gray-400 hover:text-white transition-colors" aria-label="LinkedIn">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className="text-gray-400 hover:text-white transition-colors" aria-label="Twitter">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className="text-gray-400 hover:text-white transition-colors" aria-label="Facebook">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4 text-sm uppercase tracking-wider">
|
||||
Quick Links
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link.path}>
|
||||
<Link
|
||||
href={link.path}
|
||||
className="text-sm hover:text-white transition-colors"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Product Categories */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4 text-sm uppercase tracking-wider">
|
||||
Products
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{productCategories.map((link) => (
|
||||
<li key={link.path}>
|
||||
<Link
|
||||
href={link.path}
|
||||
className="text-sm hover:text-white transition-colors"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4 text-sm uppercase tracking-wider">
|
||||
Contact
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>info@klz-cables.com</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span>+49 (0) 123 456 789</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
Industrial Street 123<br />
|
||||
12345 Berlin, Germany
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-gray-800 pt-6 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-gray-400">
|
||||
© {currentYear} {siteName}. All rights reserved.
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{legalLinks.map((link) => (
|
||||
<Link
|
||||
key={link.path}
|
||||
href={link.path}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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';
|
||||
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
|
||||
import { MobileMenu } from './MobileMenu';
|
||||
|
||||
interface HeaderProps {
|
||||
locale: string;
|
||||
siteName?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export function Header({ locale, siteName = 'KLZ Cables', logo }: HeaderProps) {
|
||||
const isSvgLogo = logo?.endsWith('.svg');
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200 shadow-sm">
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo and Branding */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{logo ? (
|
||||
<div className="h-8 sm:h-10 md:h-12 w-auto flex items-center justify-center">
|
||||
{isSvgLogo ? (
|
||||
// For SVG, use img tag with proper path handling
|
||||
<img
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
className="h-full w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
// For other images, use Next.js Image with optimized sizes
|
||||
<div className="relative h-8 sm:h-10 md:h-12 w-auto">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 120px, 144px"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 sm:w-10 md:w-12 h-8 sm:h-10 md:h-12 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xs sm:text-sm">KLZ</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:block font-bold text-lg md:text-xl text-gray-900">
|
||||
{siteName}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<Navigation locale={locale} variant="header" />
|
||||
<LocaleSwitcher />
|
||||
<Link href={`/${locale}/contact`}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Contact Us
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileMenu locale={locale} siteName={siteName} logo={logo} />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Header } from './Header';
|
||||
import { Footer } from './Footer';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
locale: string;
|
||||
siteName?: string;
|
||||
logo?: string;
|
||||
showSidebar?: boolean;
|
||||
breadcrumb?: Array<{ title: string; path: string }>;
|
||||
}
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
locale,
|
||||
siteName = 'KLZ Cables',
|
||||
logo,
|
||||
showSidebar = false,
|
||||
breadcrumb
|
||||
}: LayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<Header
|
||||
locale={locale}
|
||||
siteName={siteName}
|
||||
logo={logo}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1">
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<div className="bg-gray-50 border-b border-gray-200">
|
||||
<Container maxWidth="6xl" padding="md">
|
||||
<nav className="flex items-center gap-2 text-sm text-gray-600 py-3" aria-label="Breadcrumb">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
{breadcrumb.map((item, index) => (
|
||||
<div key={item.path} className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{index === breadcrumb.length - 1 ? (
|
||||
<span className="text-gray-900 font-medium">{item.title}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.path}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Container maxWidth="6xl" padding="md" className="py-8 md:py-12">
|
||||
{children}
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer locale={locale} siteName={siteName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
interface MobileMenuProps {
|
||||
locale: string;
|
||||
siteName: string;
|
||||
logo?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function MobileMenu({ locale, siteName, logo, onClose }: MobileMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Main navigation menu
|
||||
const mainMenu = [
|
||||
{ title: 'Home', path: `/${locale}` },
|
||||
{ title: 'Blog', path: `/${locale}/blog` },
|
||||
{ title: 'Products', path: `/${locale}/products` },
|
||||
{ title: 'Contact', path: `/${locale}/contact` }
|
||||
];
|
||||
|
||||
// Product categories (could be dynamic from data)
|
||||
const productCategories = [
|
||||
{ title: 'Medium Voltage', path: `/${locale}/product-category/medium-voltage` },
|
||||
{ title: 'Low Voltage', path: `/${locale}/product-category/low-voltage` },
|
||||
{ title: 'Accessories', path: `/${locale}/product-category/accessories` }
|
||||
];
|
||||
|
||||
// Close on route change
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}, [pathname, onClose]);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen && onClose) onClose();
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
const isSvgLogo = logo?.endsWith('.svg');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Toggle Button */}
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="md:hidden p-3 rounded-lg hover:bg-gray-100 active:bg-gray-200 transition-colors touch-target-sm"
|
||||
aria-label="Toggle mobile menu"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-700"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-50 md:hidden"
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Drawer */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out md:hidden safe-area-p ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation menu"
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 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">
|
||||
{logo ? (
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
{isSvgLogo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={siteName}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="40px"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<span className="text-white font-bold text-sm">KLZ</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-gray-900 text-lg">{siteName}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMenu}
|
||||
className="p-3 rounded-lg hover:bg-gray-100 active:bg-gray-200 transition-colors touch-target-sm"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Main Navigation */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Navigation
|
||||
</h3>
|
||||
<nav className="space-y-1">
|
||||
{mainMenu.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className="flex items-center justify-between px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 hover:text-primary active:bg-gray-200 transition-colors touch-target-md"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<span className="font-medium text-base">{item.title}</span>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Product Categories */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Product Categories
|
||||
</h3>
|
||||
<nav className="space-y-1">
|
||||
{productCategories.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className="flex items-center justify-between px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 hover:text-primary active:bg-gray-200 transition-colors touch-target-md"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<span className="font-medium text-base">{item.title}</span>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Language
|
||||
</h3>
|
||||
<div className="px-3">
|
||||
<LocaleSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Contact
|
||||
</h3>
|
||||
<div className="space-y-2 px-4 text-sm text-gray-600">
|
||||
<a
|
||||
href="mailto:info@klz-cables.com"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors touch-target-sm"
|
||||
>
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="font-medium">info@klz-cables.com</span>
|
||||
</a>
|
||||
<a
|
||||
href="tel:+490123456789"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors touch-target-sm"
|
||||
>
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span className="font-medium">+49 (0) 123 456 789</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<Link href={`/${locale}/contact`} onClick={closeMenu} className="block w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
>
|
||||
Get in Touch
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { getLocaleFromPath } from '@/lib/i18n';
|
||||
|
||||
interface NavigationProps {
|
||||
locale: string;
|
||||
variant?: 'header' | 'footer';
|
||||
}
|
||||
|
||||
export function Navigation({ locale, variant = 'header' }: NavigationProps) {
|
||||
const pathname = usePathname();
|
||||
const currentLocale = getLocaleFromPath(pathname);
|
||||
|
||||
// Main navigation menu
|
||||
const mainMenu = [
|
||||
{ title: 'Home', path: `/${locale}` },
|
||||
{ title: 'Blog', path: `/${locale}/blog` },
|
||||
{ title: 'Products', path: `/${locale}/products` },
|
||||
{ title: 'Contact', path: `/${locale}/contact` }
|
||||
];
|
||||
|
||||
// Determine styles based on variant
|
||||
const isHeader = variant === 'header';
|
||||
const baseClasses = isHeader
|
||||
? 'hidden md:flex items-center gap-1'
|
||||
: 'flex flex-col gap-2';
|
||||
|
||||
const linkClasses = isHeader
|
||||
? 'px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary hover:bg-primary-light rounded-lg transition-colors relative'
|
||||
: 'text-sm text-gray-600 hover:text-primary transition-colors';
|
||||
|
||||
const activeClasses = isHeader
|
||||
? 'text-primary bg-primary-light font-semibold'
|
||||
: 'text-primary font-medium';
|
||||
|
||||
return (
|
||||
<nav className={baseClasses}>
|
||||
{mainMenu.map((item) => {
|
||||
const isActive = pathname === item.path ||
|
||||
(item.path !== `/${locale}` && pathname.startsWith(item.path));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className={`${linkClasses} ${isActive ? activeClasses : ''}`}
|
||||
>
|
||||
{item.title}
|
||||
{isActive && isHeader && (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-0.5 bg-primary rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getViewport } from '@/lib/responsive';
|
||||
|
||||
interface ResponsiveWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
// Visibility control
|
||||
showOn?: ('mobile' | 'tablet' | 'desktop' | 'largeDesktop')[];
|
||||
hideOn?: ('mobile' | 'tablet' | 'desktop' | 'largeDesktop')[];
|
||||
// Mobile-specific behavior
|
||||
stackOnMobile?: boolean;
|
||||
centerOnMobile?: boolean;
|
||||
// Padding control
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'responsive';
|
||||
// Container control
|
||||
container?: boolean;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponsiveWrapper Component
|
||||
* Provides comprehensive responsive behavior for any content
|
||||
*/
|
||||
export function ResponsiveWrapper({
|
||||
children,
|
||||
className = '',
|
||||
showOn,
|
||||
hideOn,
|
||||
stackOnMobile = false,
|
||||
centerOnMobile = false,
|
||||
padding = 'md',
|
||||
container = false,
|
||||
maxWidth = 'xl',
|
||||
}: ResponsiveWrapperProps) {
|
||||
// Get visibility classes
|
||||
const getVisibilityClasses = () => {
|
||||
let classes = '';
|
||||
|
||||
if (showOn) {
|
||||
// Hide by default, show only on specified breakpoints
|
||||
classes += 'hidden ';
|
||||
if (showOn.includes('mobile')) classes += 'xs:block ';
|
||||
if (showOn.includes('tablet')) classes += 'md:block ';
|
||||
if (showOn.includes('desktop')) classes += 'lg:block ';
|
||||
if (showOn.includes('largeDesktop')) classes += 'xl:block ';
|
||||
}
|
||||
|
||||
if (hideOn) {
|
||||
// Show by default, hide on specified breakpoints
|
||||
if (hideOn.includes('mobile')) classes += 'xs:hidden ';
|
||||
if (hideOn.includes('tablet')) classes += 'md:hidden ';
|
||||
if (hideOn.includes('desktop')) classes += 'lg:hidden ';
|
||||
if (hideOn.includes('largeDesktop')) classes += 'xl:hidden ';
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
// Get mobile-specific classes
|
||||
const getMobileClasses = () => {
|
||||
let classes = '';
|
||||
|
||||
if (stackOnMobile) {
|
||||
classes += 'flex-col xs:flex-row ';
|
||||
}
|
||||
|
||||
if (centerOnMobile) {
|
||||
classes += 'items-center xs:items-start text-center xs:text-left ';
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
// Get padding classes
|
||||
const getPaddingClasses = () => {
|
||||
switch (padding) {
|
||||
case 'none':
|
||||
return '';
|
||||
case 'sm':
|
||||
return 'px-3 py-2 xs:px-4 xs:py-3';
|
||||
case 'md':
|
||||
return 'px-4 py-3 xs:px-6 xs:py-4';
|
||||
case 'lg':
|
||||
return 'px-5 py-4 xs:px-8 xs:py-6';
|
||||
case 'responsive':
|
||||
return 'px-4 py-3 xs:px-6 xs:py-4 md:px-8 md:py-6 lg:px-10 lg:py-8';
|
||||
default:
|
||||
return 'px-4 py-3';
|
||||
}
|
||||
};
|
||||
|
||||
// Get container classes if needed
|
||||
const getContainerClasses = () => {
|
||||
if (!container) return '';
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
|
||||
return `mx-auto ${maxWidthClasses[maxWidth]} w-full`;
|
||||
};
|
||||
|
||||
const wrapperClasses = cn(
|
||||
// Base classes
|
||||
'responsive-wrapper',
|
||||
// Visibility
|
||||
getVisibilityClasses(),
|
||||
// Mobile behavior
|
||||
getMobileClasses(),
|
||||
// Padding
|
||||
getPaddingClasses(),
|
||||
// Container
|
||||
getContainerClasses(),
|
||||
// Custom classes
|
||||
className
|
||||
);
|
||||
|
||||
return <div className={wrapperClasses}>{children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponsiveGrid Wrapper
|
||||
* Creates responsive grid layouts with mobile-first approach
|
||||
*/
|
||||
interface ResponsiveGridProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
// Column configuration
|
||||
columns?: {
|
||||
mobile?: number;
|
||||
tablet?: number;
|
||||
desktop?: number;
|
||||
largeDesktop?: number;
|
||||
};
|
||||
gap?: 'none' | 'sm' | 'md' | 'lg' | 'responsive';
|
||||
// Mobile stacking
|
||||
stackMobile?: boolean;
|
||||
// Alignment
|
||||
alignItems?: 'start' | 'center' | 'end' | 'stretch';
|
||||
justifyItems?: 'start' | 'center' | 'end' | 'stretch';
|
||||
}
|
||||
|
||||
export function ResponsiveGrid({
|
||||
children,
|
||||
className = '',
|
||||
columns = {},
|
||||
gap = 'md',
|
||||
stackMobile = false,
|
||||
alignItems = 'start',
|
||||
justifyItems = 'start',
|
||||
}: ResponsiveGridProps) {
|
||||
const getGridColumns = () => {
|
||||
if (stackMobile) {
|
||||
return `grid-cols-1 sm:grid-cols-2 md:grid-cols-${columns.tablet || 3} lg:grid-cols-${columns.desktop || 4}`;
|
||||
}
|
||||
|
||||
const mobile = columns.mobile || 1;
|
||||
const tablet = columns.tablet || 2;
|
||||
const desktop = columns.desktop || 3;
|
||||
const largeDesktop = columns.largeDesktop || 4;
|
||||
|
||||
return `grid-cols-${mobile} sm:grid-cols-${tablet} md:grid-cols-${desktop} lg:grid-cols-${largeDesktop}`;
|
||||
};
|
||||
|
||||
const getGapClasses = () => {
|
||||
switch (gap) {
|
||||
case 'none':
|
||||
return 'gap-0';
|
||||
case 'sm':
|
||||
return 'gap-2 sm:gap-3 md:gap-4';
|
||||
case 'md':
|
||||
return 'gap-3 sm:gap-4 md:gap-6 lg:gap-8';
|
||||
case 'lg':
|
||||
return 'gap-4 sm:gap-6 md:gap-8 lg:gap-12';
|
||||
case 'responsive':
|
||||
return 'gap-2 xs:gap-3 sm:gap-4 md:gap-6 lg:gap-8 xl:gap-12';
|
||||
default:
|
||||
return 'gap-4';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
'w-full',
|
||||
getGridColumns(),
|
||||
getGapClasses(),
|
||||
alignItems && `items-${alignItems}`,
|
||||
justifyItems && `justify-items-${justifyItems}`,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponsiveStack Wrapper
|
||||
* Creates vertical stack that becomes horizontal on larger screens
|
||||
*/
|
||||
interface ResponsiveStackProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
gap?: 'none' | 'sm' | 'md' | 'lg' | 'responsive';
|
||||
reverseOnMobile?: boolean;
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
export function ResponsiveStack({
|
||||
children,
|
||||
className = '',
|
||||
gap = 'md',
|
||||
reverseOnMobile = false,
|
||||
wrap = false,
|
||||
}: ResponsiveStackProps) {
|
||||
const getGapClasses = () => {
|
||||
switch (gap) {
|
||||
case 'none':
|
||||
return 'gap-0';
|
||||
case 'sm':
|
||||
return 'gap-2 sm:gap-3 md:gap-4';
|
||||
case 'md':
|
||||
return 'gap-3 sm:gap-4 md:gap-6 lg:gap-8';
|
||||
case 'lg':
|
||||
return 'gap-4 sm:gap-6 md:gap-8 lg:gap-12';
|
||||
case 'responsive':
|
||||
return 'gap-2 xs:gap-3 sm:gap-4 md:gap-6 lg:gap-8';
|
||||
default:
|
||||
return 'gap-4';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
// Mobile-first: column, then row
|
||||
'flex-col',
|
||||
'xs:flex-row',
|
||||
// Gap
|
||||
getGapClasses(),
|
||||
// Wrap
|
||||
wrap ? 'flex-wrap xs:flex-nowrap' : 'flex-nowrap',
|
||||
// Reverse on mobile
|
||||
reverseOnMobile && 'flex-col-reverse xs:flex-row',
|
||||
// Ensure proper spacing
|
||||
'w-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponsiveSection Wrapper
|
||||
* Optimized section with responsive padding and max-width
|
||||
*/
|
||||
interface ResponsiveSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'responsive';
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
|
||||
centered?: boolean;
|
||||
safeArea?: boolean;
|
||||
}
|
||||
|
||||
export function ResponsiveSection({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'responsive',
|
||||
maxWidth = '6xl',
|
||||
centered = true,
|
||||
safeArea = false,
|
||||
}: ResponsiveSectionProps) {
|
||||
const getPaddingClasses = () => {
|
||||
switch (padding) {
|
||||
case 'none':
|
||||
return '';
|
||||
case 'sm':
|
||||
return 'px-3 py-4 xs:px-4 xs:py-6 md:px-6 md:py-8';
|
||||
case 'md':
|
||||
return 'px-4 py-6 xs:px-6 xs:py-8 md:px-8 md:py-12';
|
||||
case 'lg':
|
||||
return 'px-5 py-8 xs:px-8 xs:py-12 md:px-12 md:py-16';
|
||||
case 'xl':
|
||||
return 'px-6 py-10 xs:px-10 xs:py-14 md:px-16 md:py-20';
|
||||
case 'responsive':
|
||||
return 'px-4 py-6 xs:px-6 xs:py-8 md:px-8 md:py-12 lg:px-12 lg:py-16 xl:px-16 xl:py-20';
|
||||
default:
|
||||
return 'px-4 py-6';
|
||||
}
|
||||
};
|
||||
|
||||
const getMaxWidthClasses = () => {
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
'5xl': 'max-w-5xl',
|
||||
'6xl': 'max-w-6xl',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
return maxWidthMap[maxWidth];
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'w-full',
|
||||
centered && 'mx-auto',
|
||||
getMaxWidthClasses(),
|
||||
getPaddingClasses(),
|
||||
safeArea && 'safe-area-p',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponsiveWrapper;
|
||||
@@ -1,6 +0,0 @@
|
||||
// Layout Components
|
||||
export { Header } from './Header';
|
||||
export { Footer } from './Footer';
|
||||
export { Layout } from './Layout';
|
||||
export { MobileMenu } from './MobileMenu';
|
||||
export { Navigation } from './Navigation';
|
||||
@@ -1,162 +0,0 @@
|
||||
import React, { forwardRef, ReactNode, HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Badge variants
|
||||
type BadgeVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
||||
|
||||
// Badge sizes
|
||||
type BadgeSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Badge props interface
|
||||
interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
rounded?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Helper function to get variant styles
|
||||
const getVariantStyles = (variant: BadgeVariant) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'bg-primary text-white';
|
||||
case 'secondary':
|
||||
return 'bg-secondary text-white';
|
||||
case 'success':
|
||||
return 'bg-success text-white';
|
||||
case 'warning':
|
||||
return 'bg-warning text-gray-900';
|
||||
case 'error':
|
||||
return 'bg-danger text-white';
|
||||
case 'info':
|
||||
return 'bg-info text-white';
|
||||
case 'neutral':
|
||||
return 'bg-gray-200 text-gray-800';
|
||||
default:
|
||||
return 'bg-primary text-white';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get size styles
|
||||
const getSizeStyles = (size: BadgeSize) => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'text-xs px-2 py-0.5';
|
||||
case 'md':
|
||||
return 'text-sm px-3 py-1';
|
||||
case 'lg':
|
||||
return 'text-base px-4 py-1.5';
|
||||
default:
|
||||
return 'text-sm px-3 py-1';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get icon spacing
|
||||
const getIconSpacing = (size: BadgeSize, iconPosition: 'left' | 'right') => {
|
||||
const spacing = {
|
||||
sm: iconPosition === 'left' ? 'mr-1' : 'ml-1',
|
||||
md: iconPosition === 'left' ? 'mr-1.5' : 'ml-1.5',
|
||||
lg: iconPosition === 'left' ? 'mr-2' : 'ml-2',
|
||||
};
|
||||
return spacing[size];
|
||||
};
|
||||
|
||||
// Helper function to get icon size
|
||||
const getIconSize = (size: BadgeSize) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
return sizeClasses[size];
|
||||
};
|
||||
|
||||
// Main Badge Component
|
||||
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
rounded = true,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center font-medium',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
// Variant styles
|
||||
getVariantStyles(variant),
|
||||
// Size styles
|
||||
getSizeStyles(size),
|
||||
// Border radius
|
||||
rounded ? 'rounded-full' : 'rounded-md',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Icon - Left position */}
|
||||
{icon && iconPosition === 'left' && (
|
||||
<span className={cn('flex items-center justify-center', getIconSpacing(size, 'left'), getIconSize(size))}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Badge content */}
|
||||
{children && <span>{children}</span>}
|
||||
|
||||
{/* Icon - Right position */}
|
||||
{icon && iconPosition === 'right' && (
|
||||
<span className={cn('flex items-center justify-center', getIconSpacing(size, 'right'), getIconSize(size))}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
// Badge Group Component for multiple badges
|
||||
interface BadgeGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
gap?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const BadgeGroup = forwardRef<HTMLDivElement, BadgeGroupProps>(
|
||||
({ gap = 'sm', className = '', children, ...props }, ref) => {
|
||||
const gapClasses = {
|
||||
xs: 'gap-1',
|
||||
sm: 'gap-2',
|
||||
md: 'gap-3',
|
||||
lg: 'gap-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-wrap items-center', gapClasses[gap], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BadgeGroup.displayName = 'BadgeGroup';
|
||||
|
||||
// Export types for external use
|
||||
export type { BadgeProps, BadgeVariant, BadgeSize, BadgeGroupProps };
|
||||
@@ -1,220 +0,0 @@
|
||||
import React, { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getViewport, getTouchTargetSize } from '../../lib/responsive';
|
||||
|
||||
// Button variants
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
|
||||
// Button sizes
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Button props interface
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
fullWidth?: boolean;
|
||||
responsiveSize?: {
|
||||
mobile?: ButtonSize;
|
||||
tablet?: ButtonSize;
|
||||
desktop?: ButtonSize;
|
||||
};
|
||||
touchTarget?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to get variant styles
|
||||
const getVariantStyles = (variant: ButtonVariant, disabled?: boolean) => {
|
||||
const baseStyles = 'transition-all duration-200 ease-in-out font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
if (disabled) {
|
||||
return `${baseStyles} bg-gray-300 text-gray-500 cursor-not-allowed opacity-60`;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return `${baseStyles} bg-primary hover:bg-primary-dark text-white focus:ring-primary`;
|
||||
case 'secondary':
|
||||
return `${baseStyles} bg-secondary hover:bg-secondary-light text-white focus:ring-secondary`;
|
||||
case 'outline':
|
||||
return `${baseStyles} bg-transparent border-2 border-primary text-primary hover:bg-primary-light hover:border-primary-dark focus:ring-primary`;
|
||||
case 'ghost':
|
||||
return `${baseStyles} bg-transparent text-primary hover:bg-primary-light focus:ring-primary`;
|
||||
default:
|
||||
return `${baseStyles} bg-primary hover:bg-primary-dark text-white`;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get size styles
|
||||
const getSizeStyles = (size: ButtonSize) => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'px-3 py-1.5 text-sm';
|
||||
case 'md':
|
||||
return 'px-4 py-2 text-base';
|
||||
case 'lg':
|
||||
return 'px-6 py-3 text-lg';
|
||||
default:
|
||||
return 'px-4 py-2 text-base';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get icon spacing
|
||||
const getIconSpacing = (size: ButtonSize, iconPosition: 'left' | 'right') => {
|
||||
const spacing = {
|
||||
sm: iconPosition === 'left' ? 'mr-1.5' : 'ml-1.5',
|
||||
md: iconPosition === 'left' ? 'mr-2' : 'ml-2',
|
||||
lg: iconPosition === 'left' ? 'mr-2.5' : 'ml-2.5',
|
||||
};
|
||||
return spacing[size];
|
||||
};
|
||||
|
||||
// Loading spinner component
|
||||
const LoadingSpinner = ({ size }: { size: ButtonSize }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('animate-spin', sizeClasses[size])}>
|
||||
<svg
|
||||
className="w-full h-full text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Button component
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
className = '',
|
||||
children,
|
||||
type = 'button',
|
||||
responsiveSize,
|
||||
touchTarget = true,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Get responsive size if provided
|
||||
const getResponsiveSize = () => {
|
||||
if (!responsiveSize) return size;
|
||||
|
||||
if (typeof window === 'undefined') return size;
|
||||
|
||||
const viewport = getViewport();
|
||||
|
||||
if (viewport.isMobile && responsiveSize.mobile) {
|
||||
return responsiveSize.mobile;
|
||||
}
|
||||
if (viewport.isTablet && responsiveSize.tablet) {
|
||||
return responsiveSize.tablet;
|
||||
}
|
||||
if (viewport.isDesktop && responsiveSize.desktop) {
|
||||
return responsiveSize.desktop;
|
||||
}
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
const responsiveSizeValue = getResponsiveSize();
|
||||
|
||||
// Get touch target size - fixed for hydration
|
||||
const getTouchTargetClasses = () => {
|
||||
if (!touchTarget) return '';
|
||||
|
||||
// Always return the same classes to avoid hydration mismatch
|
||||
// The touch target is a design requirement that should be consistent
|
||||
return `min-h-[44px] min-w-[44px]`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center font-semibold',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
// Base styles
|
||||
'rounded-lg',
|
||||
// Variant styles
|
||||
getVariantStyles(variant, isDisabled),
|
||||
// Size styles (responsive)
|
||||
getSizeStyles(responsiveSizeValue),
|
||||
// Touch target optimization
|
||||
getTouchTargetClasses(),
|
||||
// Full width
|
||||
fullWidth ? 'w-full' : '',
|
||||
// Mobile-specific optimizations
|
||||
'active:scale-95 md:active:scale-100',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
// Add aria-label for accessibility if button has only icon
|
||||
aria-label={!children && icon ? 'Button action' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<span className={cn('flex items-center justify-center', getIconSpacing(responsiveSizeValue, 'left'))}>
|
||||
<LoadingSpinner size={responsiveSizeValue} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Icon - Left position */}
|
||||
{!loading && icon && iconPosition === 'left' && (
|
||||
<span className={cn('flex items-center justify-center', getIconSpacing(responsiveSizeValue, 'left'))}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Button content */}
|
||||
{children && <span className="leading-none">{children}</span>}
|
||||
|
||||
{/* Icon - Right position */}
|
||||
{!loading && icon && iconPosition === 'right' && (
|
||||
<span className={cn('flex items-center justify-center', getIconSpacing(responsiveSizeValue, 'right'))}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
// Export types for external use
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize };
|
||||
@@ -1,236 +0,0 @@
|
||||
# UI Components Summary
|
||||
|
||||
## ✅ Task Completed Successfully
|
||||
|
||||
All core UI components have been created and are ready for use in the KLZ Cables Next.js application.
|
||||
|
||||
## 📁 Files Created
|
||||
|
||||
### Core Components (6)
|
||||
1. **`Button.tsx`** - Versatile button with variants, sizes, icons, and loading states
|
||||
2. **`Card.tsx`** - Flexible container with header, body, footer, and image support
|
||||
3. **`Container.tsx`** - Responsive wrapper with configurable max-width and padding
|
||||
4. **`Grid.tsx`** - Responsive grid system with 1-12 columns and breakpoints
|
||||
5. **`Badge.tsx`** - Status labels with colors, sizes, and icons
|
||||
6. **`Loading.tsx`** - Spinner animations, loading buttons, and skeleton loaders
|
||||
|
||||
### Supporting Files
|
||||
7. **`index.ts`** - Central export file for all components
|
||||
8. **`ComponentsExample.tsx`** - Comprehensive examples showing all component variations
|
||||
9. **`README.md`** - Complete documentation with usage examples
|
||||
10. **`COMPONENTS_SUMMARY.md`** - This summary file
|
||||
|
||||
### Utility Files
|
||||
11. **`lib/utils.ts`** - Utility functions including `cn()` for class merging
|
||||
|
||||
## 🎨 Design System Foundation
|
||||
|
||||
### Colors (from tailwind.config.js)
|
||||
- **Primary**: `#0056b3` (KLZ blue)
|
||||
- **Secondary**: `#003d82` (darker blue)
|
||||
- **Accent**: `#e6f0ff` (light blue)
|
||||
- **Semantic**: Success, Warning, Error, Info
|
||||
- **Neutral**: Grays for backgrounds and text
|
||||
|
||||
### Typography
|
||||
- **Font**: Inter (system-ui fallback)
|
||||
- **Scale**: xs (0.75rem) to 6xl (3.75rem)
|
||||
- **Weights**: 400, 500, 600, 700, 800
|
||||
|
||||
### Spacing
|
||||
- **Scale**: xs (4px) to 4xl (96px)
|
||||
- **Consistent**: Used across all components
|
||||
|
||||
### Breakpoints
|
||||
- **sm**: 640px
|
||||
- **md**: 768px
|
||||
- **lg**: 1024px
|
||||
- **xl**: 1280px
|
||||
- **2xl**: 1400px
|
||||
|
||||
## 📋 Component Features
|
||||
|
||||
### Button Component
|
||||
```tsx
|
||||
// Variants: primary, secondary, outline, ghost
|
||||
// Sizes: sm, md, lg
|
||||
// Features: icons, loading, disabled, fullWidth
|
||||
<Button variant="primary" size="md" icon={<Icon />} loading>
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Card Component
|
||||
```tsx
|
||||
// Variants: elevated, flat, bordered
|
||||
// Padding: none, sm, md, lg, xl
|
||||
// Features: hoverable, image support, composable
|
||||
<Card variant="elevated" padding="lg" hoverable>
|
||||
<CardHeader title="Title" />
|
||||
<CardBody>Content</CardBody>
|
||||
<CardFooter>Actions</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Container Component
|
||||
```tsx
|
||||
// Max-width: xs to 6xl, full
|
||||
// Padding: none, sm, md, lg, xl, 2xl
|
||||
// Features: centered, fluid
|
||||
<Container maxWidth="6xl" padding="lg">
|
||||
<YourContent />
|
||||
</Container>
|
||||
```
|
||||
|
||||
### Grid Component
|
||||
```tsx
|
||||
// Columns: 1-12
|
||||
// Responsive: colsSm, colsMd, colsLg, colsXl
|
||||
// Gaps: none, xs, sm, md, lg, xl, 2xl
|
||||
<Grid cols={1} colsMd={2} colsLg={4} gap="lg">
|
||||
<GridItem colSpan={2}>Wide</GridItem>
|
||||
<GridItem>Normal</GridItem>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
### Badge Component
|
||||
```tsx
|
||||
// Variants: primary, secondary, success, warning, error, info, neutral
|
||||
// Sizes: sm, md, lg
|
||||
// Features: icons, rounded
|
||||
<Badge variant="success" size="md" icon={<CheckIcon />}>
|
||||
Active
|
||||
</Badge>
|
||||
```
|
||||
|
||||
### Loading Component
|
||||
```tsx
|
||||
// Sizes: sm, md, lg, xl
|
||||
// Variants: primary, secondary, neutral, contrast
|
||||
// Features: overlay, fullscreen, text, skeletons
|
||||
<Loading size="md" overlay text="Loading..." />
|
||||
<LoadingButton text="Processing..." />
|
||||
<LoadingSkeleton width="100%" height="1rem" />
|
||||
```
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### TypeScript Support
|
||||
- ✅ Fully typed components
|
||||
- ✅ Exported prop interfaces
|
||||
- ✅ Type-safe variants and sizes
|
||||
- ✅ IntelliSense support
|
||||
|
||||
### Accessibility
|
||||
- ✅ ARIA attributes where needed
|
||||
- ✅ Keyboard navigation support
|
||||
- ✅ Focus management
|
||||
- ✅ Screen reader friendly
|
||||
- ✅ Color contrast compliance
|
||||
|
||||
### Responsive Design
|
||||
- ✅ Mobile-first approach
|
||||
- ✅ Tailwind responsive prefixes
|
||||
- ✅ Flexible layouts
|
||||
- ✅ Touch-friendly sizes
|
||||
|
||||
### Performance
|
||||
- ✅ Lightweight components
|
||||
- ✅ Tree-shakeable exports
|
||||
- ✅ No inline styles
|
||||
- ✅ Optimized Tailwind classes
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Quick Start
|
||||
```tsx
|
||||
// Import from central index
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Badge,
|
||||
Loading
|
||||
} from '@/components/ui';
|
||||
|
||||
// Use in your components
|
||||
export default function MyPage() {
|
||||
return (
|
||||
<Container maxWidth="lg" padding="md">
|
||||
<Grid cols={1} colsMd={2} gap="md">
|
||||
<Card variant="elevated" padding="lg">
|
||||
<CardHeader title="Welcome" />
|
||||
<CardBody>
|
||||
<p>Content goes here</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button variant="primary">Get Started</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Customization
|
||||
All components support the `className` prop:
|
||||
```tsx
|
||||
<Button
|
||||
variant="primary"
|
||||
className="hover:scale-105 transition-transform"
|
||||
>
|
||||
Custom Button
|
||||
</Button>
|
||||
```
|
||||
|
||||
## 📊 Component Statistics
|
||||
|
||||
- **Total Components**: 6 core + 3 sub-components
|
||||
- **Lines of Code**: ~1,500
|
||||
- **TypeScript Interfaces**: 20+
|
||||
- **Exported Types**: 30+
|
||||
- **Examples**: 50+ variations
|
||||
- **Documentation**: Complete
|
||||
|
||||
## 🎯 Design Principles
|
||||
|
||||
1. **Consistency**: All components follow the same patterns
|
||||
2. **Flexibility**: Props allow for extensive customization
|
||||
3. **Accessibility**: Built with WCAG guidelines in mind
|
||||
4. **Performance**: Optimized for production use
|
||||
5. **Developer Experience**: TypeScript-first, well-documented
|
||||
|
||||
## 🔧 Next Steps
|
||||
|
||||
The components are ready to use immediately. You can:
|
||||
|
||||
1. **Start building**: Import and use components in your pages
|
||||
2. **Customize**: Add your own variants or extend existing ones
|
||||
3. **Test**: Run the development server to see examples
|
||||
4. **Expand**: Add more components as needed (modals, forms, etc.)
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
For detailed usage examples, see:
|
||||
- `README.md` - Complete component documentation
|
||||
- `ComponentsExample.tsx` - Live examples of all components
|
||||
- Individual component files - Inline documentation
|
||||
|
||||
## ✅ Quality Checklist
|
||||
|
||||
- [x] All components created with TypeScript
|
||||
- [x] Proper prop interfaces and types
|
||||
- [x] Accessibility attributes included
|
||||
- [x] Responsive design implemented
|
||||
- [x] Tailwind CSS classes (no inline styles)
|
||||
- [x] className prop support
|
||||
- [x] forwardRef for better ref handling
|
||||
- [x] Comprehensive examples
|
||||
- [x] Complete documentation
|
||||
- [x] Centralized exports
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **COMPLETE** - All components are production-ready!
|
||||
@@ -1,265 +0,0 @@
|
||||
import React, { forwardRef, ReactNode, HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Card variants
|
||||
type CardVariant = 'elevated' | 'flat' | 'bordered';
|
||||
|
||||
// Card props interface
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CardVariant;
|
||||
children?: ReactNode;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
hoverable?: boolean;
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
// Card header props
|
||||
interface CardHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
// Card body props
|
||||
interface CardBodyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Card footer props
|
||||
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Card image props
|
||||
interface CardImageProps extends HTMLAttributes<HTMLDivElement> {
|
||||
src: string;
|
||||
alt?: string;
|
||||
height?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
position?: 'top' | 'background';
|
||||
}
|
||||
|
||||
// Helper function to get variant styles
|
||||
const getVariantStyles = (variant: CardVariant) => {
|
||||
switch (variant) {
|
||||
case 'elevated':
|
||||
return 'bg-white shadow-lg shadow-gray-200/50 border border-gray-100';
|
||||
case 'flat':
|
||||
return 'bg-white shadow-sm border border-gray-100';
|
||||
case 'bordered':
|
||||
return 'bg-white border-2 border-gray-200';
|
||||
default:
|
||||
return 'bg-white shadow-md border border-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get padding styles
|
||||
const getPaddingStyles = (padding: CardProps['padding']) => {
|
||||
switch (padding) {
|
||||
case 'none':
|
||||
return '';
|
||||
case 'sm':
|
||||
return 'p-3';
|
||||
case 'md':
|
||||
return 'p-4';
|
||||
case 'lg':
|
||||
return 'p-6';
|
||||
case 'xl':
|
||||
return 'p-8';
|
||||
default:
|
||||
return 'p-4';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get image height
|
||||
const getImageHeight = (height: CardImageProps['height']) => {
|
||||
switch (height) {
|
||||
case 'sm':
|
||||
return 'h-32';
|
||||
case 'md':
|
||||
return 'h-48';
|
||||
case 'lg':
|
||||
return 'h-64';
|
||||
case 'xl':
|
||||
return 'h-80';
|
||||
default:
|
||||
return 'h-48';
|
||||
}
|
||||
};
|
||||
|
||||
// Main Card Component
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{
|
||||
variant = 'elevated',
|
||||
padding = 'md',
|
||||
hoverable = false,
|
||||
shadow = true,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
// Variant styles
|
||||
getVariantStyles(variant),
|
||||
// Padding
|
||||
getPaddingStyles(padding),
|
||||
// Hover effect
|
||||
hoverable && 'hover:shadow-xl hover:shadow-gray-200/70 hover:-translate-y-1',
|
||||
// Shadow override
|
||||
!shadow && 'shadow-none',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
// Card Header Component
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ title, subtitle, icon, action, className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-4',
|
||||
'border-b border-gray-100 pb-4 mb-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{icon && <div className="text-gray-500 mt-0.5">{icon}</div>}
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<div className="text-lg font-semibold text-gray-900 leading-tight">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-sm text-gray-600 mt-1 leading-relaxed">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
// Card Body Component
|
||||
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('space-y-3', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardBody.displayName = 'CardBody';
|
||||
|
||||
// Card Footer Component
|
||||
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
||||
({ align = 'left', className = '', children, ...props }, ref) => {
|
||||
const alignmentClasses = {
|
||||
left: 'justify-start',
|
||||
center: 'justify-center',
|
||||
right: 'justify-end',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center gap-3',
|
||||
'border-t border-gray-100 pt-4 mt-4',
|
||||
alignmentClasses[align],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
// Card Image Component
|
||||
export const CardImage = forwardRef<HTMLDivElement, CardImageProps>(
|
||||
({ src, alt, height = 'md', position = 'top', className = '', ...props }, ref) => {
|
||||
const heightClasses = getImageHeight(height);
|
||||
|
||||
if (position === 'background') {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-t-lg',
|
||||
heightClasses,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full overflow-hidden rounded-t-lg',
|
||||
heightClasses,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardImage.displayName = 'CardImage';
|
||||
|
||||
// Export types for external use
|
||||
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, CardImageProps, CardVariant };
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* UI Components Example
|
||||
*
|
||||
* This file demonstrates how to use all the UI components in the design system.
|
||||
* Each component is shown with various props and configurations.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardImage,
|
||||
Container,
|
||||
Grid,
|
||||
GridItem,
|
||||
Badge,
|
||||
BadgeGroup,
|
||||
Loading,
|
||||
LoadingButton,
|
||||
LoadingSkeleton,
|
||||
} from './index';
|
||||
|
||||
// Example Icons (using simple SVG)
|
||||
const ArrowRightIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const StarIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Button Examples
|
||||
export const ButtonExamples = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Buttons</h3>
|
||||
|
||||
{/* Primary Buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="primary" size="sm">Small Primary</Button>
|
||||
<Button variant="primary" size="md">Medium Primary</Button>
|
||||
<Button variant="primary" size="lg">Large Primary</Button>
|
||||
<Button variant="primary" icon={<ArrowRightIcon />} iconPosition="right">With Icon</Button>
|
||||
<Button variant="primary" loading>Loading</Button>
|
||||
<Button variant="primary" disabled>Disabled</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="secondary" size="md">Secondary</Button>
|
||||
<Button variant="secondary" icon={<CheckIcon />}>Success</Button>
|
||||
</div>
|
||||
|
||||
{/* Outline Buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="md">Outline</Button>
|
||||
<Button variant="outline" icon={<StarIcon />}>With Icon</Button>
|
||||
</div>
|
||||
|
||||
{/* Ghost Buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="ghost" size="md">Ghost</Button>
|
||||
<Button variant="ghost" fullWidth>Full Width Ghost</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Card Examples
|
||||
export const CardExamples = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Cards</h3>
|
||||
|
||||
<Grid cols={1} colsMd={2} gap="md">
|
||||
{/* Basic Card */}
|
||||
<Card variant="elevated" padding="lg">
|
||||
<CardHeader
|
||||
title="Basic Card"
|
||||
subtitle="With header and content"
|
||||
/>
|
||||
<CardBody>
|
||||
<p>This is a basic card with elevated variant. It includes a header, body content, and footer.</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button variant="primary" size="sm">Action</Button>
|
||||
<Button variant="ghost" size="sm">Cancel</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Card with Image */}
|
||||
<Card variant="flat" padding="none">
|
||||
<CardImage
|
||||
src="https://via.placeholder.com/400x200/0056b3/ffffff?text=Card+Image"
|
||||
alt="Card image"
|
||||
height="md"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<CardHeader
|
||||
title="Card with Image"
|
||||
subtitle="Image at the top"
|
||||
/>
|
||||
<CardBody>
|
||||
<p>Cards can include images for visual appeal.</p>
|
||||
</CardBody>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bordered Card */}
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader
|
||||
title="Bordered Card"
|
||||
icon={<StarIcon />}
|
||||
action={<Badge variant="success">New</Badge>}
|
||||
/>
|
||||
<CardBody>
|
||||
<p>This card has a strong border and includes an icon in the header.</p>
|
||||
</CardBody>
|
||||
<CardFooter align="right">
|
||||
<Button variant="outline" size="sm">Learn More</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Hoverable Card */}
|
||||
<Card variant="elevated" padding="lg" hoverable>
|
||||
<CardHeader title="Hoverable Card" />
|
||||
<CardBody>
|
||||
<p>Hover over this card to see the effect!</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<BadgeGroup gap="sm">
|
||||
<Badge variant="primary">React</Badge>
|
||||
<Badge variant="secondary">TypeScript</Badge>
|
||||
<Badge variant="info">Tailwind</Badge>
|
||||
</BadgeGroup>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Container Examples
|
||||
export const ContainerExamples = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Containers</h3>
|
||||
|
||||
<Container maxWidth="lg" padding="lg" className="bg-gray-50 rounded-lg">
|
||||
<p className="text-center">Default Container (max-width: lg, padding: lg)</p>
|
||||
</Container>
|
||||
|
||||
<Container maxWidth="md" padding="md" className="bg-accent rounded-lg">
|
||||
<p className="text-center">Medium Container (max-width: md, padding: md)</p>
|
||||
</Container>
|
||||
|
||||
<Container fluid className="bg-primary text-white rounded-lg py-4">
|
||||
<p className="text-center">Fluid Container (full width)</p>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Grid Examples
|
||||
export const GridExamples = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Grid System</h3>
|
||||
|
||||
{/* Basic Grid */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600">12-column responsive grid:</p>
|
||||
<Grid cols={2} colsMd={4} gap="sm">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="bg-primary text-white p-4 rounded text-center">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Grid with Span */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600">Grid with column spans:</p>
|
||||
<Grid cols={3} gap="md">
|
||||
<GridItem colSpan={2} className="bg-secondary text-white p-4 rounded">
|
||||
Span 2 columns
|
||||
</GridItem>
|
||||
<GridItem className="bg-accent p-4 rounded">1 column</GridItem>
|
||||
<GridItem className="bg-warning p-4 rounded">1 column</GridItem>
|
||||
<GridItem colSpan={2} className="bg-success text-white p-4 rounded">
|
||||
Span 2 columns
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Responsive Grid */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600">Responsive (1 col mobile, 2 tablet, 3 desktop):</p>
|
||||
<Grid cols={1} colsSm={2} colsLg={3} gap="lg">
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<div key={item} className="bg-gray-200 p-6 rounded text-center font-medium">
|
||||
Item {item}
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Badge Examples
|
||||
export const BadgeExamples = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Badges</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Badge Variants */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Color Variants:</p>
|
||||
<BadgeGroup gap="sm">
|
||||
<Badge variant="primary">Primary</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
</BadgeGroup>
|
||||
</div>
|
||||
|
||||
{/* Badge Sizes */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Sizes:</p>
|
||||
<BadgeGroup gap="md">
|
||||
<Badge variant="primary" size="sm">Small</Badge>
|
||||
<Badge variant="primary" size="md">Medium</Badge>
|
||||
<Badge variant="primary" size="lg">Large</Badge>
|
||||
</BadgeGroup>
|
||||
</div>
|
||||
|
||||
{/* Badges with Icons */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">With Icons:</p>
|
||||
<BadgeGroup gap="sm">
|
||||
<Badge variant="success" icon={<CheckIcon />}>Success</Badge>
|
||||
<Badge variant="primary" icon={<StarIcon />} iconPosition="right">Star</Badge>
|
||||
<Badge variant="warning" icon={<ArrowRightIcon />}>Next</Badge>
|
||||
</BadgeGroup>
|
||||
</div>
|
||||
|
||||
{/* Rounded Badges */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Rounded:</p>
|
||||
<BadgeGroup gap="sm">
|
||||
<Badge variant="primary" rounded={true}>Rounded</Badge>
|
||||
<Badge variant="secondary" rounded={false}>Squared</Badge>
|
||||
</BadgeGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Loading Examples
|
||||
export const LoadingExamples = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Loading Components</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Spinner Sizes */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Spinner Sizes:</p>
|
||||
<div className="flex gap-4 items-center flex-wrap">
|
||||
<Loading size="sm" />
|
||||
<Loading size="md" />
|
||||
<Loading size="lg" />
|
||||
<Loading size="xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spinner Variants */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Spinner Variants:</p>
|
||||
<div className="flex gap-4 items-center flex-wrap">
|
||||
<Loading size="md" variant="primary" />
|
||||
<Loading size="md" variant="secondary" />
|
||||
<Loading size="md" variant="neutral" />
|
||||
<Loading size="md" variant="contrast" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading with Text */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">With Text:</p>
|
||||
<Loading size="md" text="Loading data..." />
|
||||
</div>
|
||||
|
||||
{/* Loading Button */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Loading Button:</p>
|
||||
<LoadingButton size="md" text="Processing..." />
|
||||
</div>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Skeleton Loaders:</p>
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton width="100%" height="1rem" />
|
||||
<LoadingSkeleton width="80%" height="1rem" />
|
||||
<LoadingSkeleton width="60%" height="1rem" rounded />
|
||||
<LoadingSkeleton width="100%" height="4rem" rounded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Combined Example - Full Page Layout
|
||||
export const FullPageExample = () => (
|
||||
<Container maxWidth="6xl" padding="lg">
|
||||
<div className="space-y-8">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-4xl font-bold text-gray-900">UI Components Showcase</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
A comprehensive design system for your Next.js application
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero Card */}
|
||||
<Card variant="elevated" padding="xl">
|
||||
<CardImage
|
||||
src="https://via.placeholder.com/1200x400/0056b3/ffffff?text=Hero+Image"
|
||||
alt="Hero"
|
||||
height="lg"
|
||||
/>
|
||||
<CardHeader
|
||||
title="Welcome to the Design System"
|
||||
subtitle="Built with accessibility and responsiveness in mind"
|
||||
icon={<StarIcon />}
|
||||
/>
|
||||
<CardBody>
|
||||
<p>
|
||||
This design system provides a complete set of reusable components
|
||||
that follow modern design principles and accessibility standards.
|
||||
</p>
|
||||
</CardBody>
|
||||
<CardFooter align="center">
|
||||
<Button variant="primary" size="lg" icon={<ArrowRightIcon />} iconPosition="right">
|
||||
Get Started
|
||||
</Button>
|
||||
<Button variant="outline" size="lg">Learn More</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Feature Grid */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4 text-center">Features</h2>
|
||||
<Grid cols={1} colsMd={2} colsLg={3} gap="lg">
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader title="Responsive" icon={<StarIcon />} />
|
||||
<CardBody>
|
||||
<p>Works perfectly on all devices from mobile to desktop.</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Badge variant="success">Ready</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader title="Accessible" icon={<CheckIcon />} />
|
||||
<CardBody>
|
||||
<p>Follows WCAG guidelines with proper ARIA attributes.</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Badge variant="info">WCAG 2.1</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader title="TypeScript" icon={<ArrowRightIcon />} />
|
||||
<CardBody>
|
||||
<p>Fully typed with TypeScript for better developer experience.</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Badge variant="primary">TypeScript</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Action Section */}
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-gray-700">Ready to start building?</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button variant="primary" size="lg">Start Building</Button>
|
||||
<Button variant="secondary" size="lg">View Documentation</Button>
|
||||
<Button variant="ghost" size="lg" icon={<StarIcon />}>Star on GitHub</Button>
|
||||
</div>
|
||||
<BadgeGroup gap="sm" className="justify-center">
|
||||
<Badge variant="primary">React</Badge>
|
||||
<Badge variant="secondary">Next.js</Badge>
|
||||
<Badge variant="info">TypeScript</Badge>
|
||||
<Badge variant="success">Tailwind CSS</Badge>
|
||||
</BadgeGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Main Export - All Components
|
||||
export const AllComponentsExample = () => {
|
||||
return (
|
||||
<div className="space-y-12 py-8">
|
||||
<ButtonExamples />
|
||||
<CardExamples />
|
||||
<ContainerExamples />
|
||||
<GridExamples />
|
||||
<BadgeExamples />
|
||||
<LoadingExamples />
|
||||
<FullPageExample />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllComponentsExample;
|
||||
@@ -1,140 +0,0 @@
|
||||
import React, { forwardRef, ReactNode, HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getViewport } from '../../lib/responsive';
|
||||
|
||||
// Container props interface
|
||||
interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'responsive';
|
||||
centered?: boolean;
|
||||
fluid?: boolean;
|
||||
safeArea?: boolean;
|
||||
responsivePadding?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to get max-width styles
|
||||
const getMaxWidthStyles = (maxWidth: ContainerProps['maxWidth']) => {
|
||||
switch (maxWidth) {
|
||||
case 'xs':
|
||||
return 'max-w-xs';
|
||||
case 'sm':
|
||||
return 'max-w-sm';
|
||||
case 'md':
|
||||
return 'max-w-md';
|
||||
case 'lg':
|
||||
return 'max-w-lg';
|
||||
case 'xl':
|
||||
return 'max-w-xl';
|
||||
case '2xl':
|
||||
return 'max-w-2xl';
|
||||
case '3xl':
|
||||
return 'max-w-3xl';
|
||||
case '4xl':
|
||||
return 'max-w-4xl';
|
||||
case '5xl':
|
||||
return 'max-w-5xl';
|
||||
case '6xl':
|
||||
return 'max-w-6xl';
|
||||
case 'full':
|
||||
return 'max-w-full';
|
||||
default:
|
||||
return 'max-w-6xl';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get padding styles
|
||||
const getPaddingStyles = (padding: ContainerProps['padding'], responsivePadding?: boolean) => {
|
||||
if (padding === 'responsive' || responsivePadding) {
|
||||
return 'px-4 xs:px-5 sm:px-6 md:px-8 lg:px-10 xl:px-12 2xl:px-16';
|
||||
}
|
||||
|
||||
switch (padding) {
|
||||
case 'none':
|
||||
return 'px-0';
|
||||
case 'sm':
|
||||
return 'px-3 xs:px-4 sm:px-5';
|
||||
case 'md':
|
||||
return 'px-4 xs:px-5 sm:px-6 md:px-8';
|
||||
case 'lg':
|
||||
return 'px-4 xs:px-5 sm:px-6 md:px-8 lg:px-10';
|
||||
case 'xl':
|
||||
return 'px-4 xs:px-5 sm:px-6 md:px-8 lg:px-10 xl:px-12';
|
||||
case '2xl':
|
||||
return 'px-4 xs:px-5 sm:px-6 md:px-8 lg:px-10 xl:px-12 2xl:px-16';
|
||||
default:
|
||||
return 'px-4 xs:px-5 sm:px-6 md:px-8 lg:px-10';
|
||||
}
|
||||
};
|
||||
|
||||
// Main Container Component
|
||||
export const Container = forwardRef<HTMLDivElement, ContainerProps>(
|
||||
(
|
||||
{
|
||||
maxWidth = '6xl',
|
||||
padding = 'md',
|
||||
centered = true,
|
||||
fluid = false,
|
||||
safeArea = false,
|
||||
responsivePadding = false,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Get responsive padding if needed
|
||||
const getResponsivePadding = () => {
|
||||
if (!responsivePadding && padding !== 'responsive') return getPaddingStyles(padding, false);
|
||||
|
||||
if (typeof window === 'undefined') return getPaddingStyles('md', true);
|
||||
|
||||
const viewport = getViewport();
|
||||
|
||||
// Mobile-first responsive padding
|
||||
if (viewport.isMobile) {
|
||||
return 'px-4 xs:px-5 sm:px-6';
|
||||
}
|
||||
if (viewport.isTablet) {
|
||||
return 'px-5 sm:px-6 md:px-8 lg:px-10';
|
||||
}
|
||||
if (viewport.isDesktop) {
|
||||
return 'px-6 md:px-8 lg:px-10 xl:px-12';
|
||||
}
|
||||
|
||||
return 'px-6 md:px-8 lg:px-10 xl:px-12 2xl:px-16';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base container styles
|
||||
'w-full',
|
||||
// Centering
|
||||
centered && 'mx-auto',
|
||||
// Max width
|
||||
!fluid && getMaxWidthStyles(maxWidth),
|
||||
// Padding (responsive or static)
|
||||
responsivePadding || padding === 'responsive' ? getResponsivePadding() : getPaddingStyles(padding, false),
|
||||
// Safe area for mobile notch
|
||||
safeArea && 'safe-area-p',
|
||||
// Mobile-optimized max width
|
||||
'mobile:max-w-full',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
// Add role for accessibility
|
||||
role="region"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Container.displayName = 'Container';
|
||||
|
||||
// Export types for external use
|
||||
export type { ContainerProps };
|
||||
@@ -1,251 +0,0 @@
|
||||
import React, { forwardRef, ReactNode, HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getViewport } from '../../lib/responsive';
|
||||
|
||||
// Grid column types
|
||||
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
|
||||
// Grid gap types
|
||||
type GridGap = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'responsive';
|
||||
|
||||
// Grid props interface
|
||||
interface GridProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
cols?: GridCols;
|
||||
gap?: GridGap;
|
||||
colsSm?: GridCols;
|
||||
colsMd?: GridCols;
|
||||
colsLg?: GridCols;
|
||||
colsXl?: GridCols;
|
||||
alignItems?: 'start' | 'center' | 'end' | 'stretch';
|
||||
justifyItems?: 'start' | 'center' | 'end' | 'stretch';
|
||||
// Mobile-first stacking
|
||||
stackMobile?: boolean;
|
||||
// Responsive columns
|
||||
responsiveCols?: {
|
||||
mobile?: GridCols;
|
||||
tablet?: GridCols;
|
||||
desktop?: GridCols;
|
||||
};
|
||||
}
|
||||
|
||||
// Grid item props interface
|
||||
interface GridItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
colSpan?: GridCols;
|
||||
colSpanSm?: GridCols;
|
||||
colSpanMd?: GridCols;
|
||||
colSpanLg?: GridCols;
|
||||
colSpanXl?: GridCols;
|
||||
rowSpan?: GridCols;
|
||||
rowSpanSm?: GridCols;
|
||||
rowSpanMd?: GridCols;
|
||||
rowSpanLg?: GridCols;
|
||||
rowSpanXl?: GridCols;
|
||||
}
|
||||
|
||||
// Helper function to get gap styles
|
||||
const getGapStyles = (gap: GridGap, responsiveGap?: boolean) => {
|
||||
if (gap === 'responsive' || responsiveGap) {
|
||||
return 'gap-2 xs:gap-3 sm:gap-4 md:gap-6 lg:gap-8';
|
||||
}
|
||||
|
||||
switch (gap) {
|
||||
case 'none':
|
||||
return 'gap-0';
|
||||
case 'xs':
|
||||
return 'gap-1';
|
||||
case 'sm':
|
||||
return 'gap-2';
|
||||
case 'md':
|
||||
return 'gap-4';
|
||||
case 'lg':
|
||||
return 'gap-6';
|
||||
case 'xl':
|
||||
return 'gap-8';
|
||||
case '2xl':
|
||||
return 'gap-12';
|
||||
default:
|
||||
return 'gap-4';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get column classes
|
||||
const getColClasses = (cols: GridCols | undefined, breakpoint: string = '') => {
|
||||
if (!cols) return '';
|
||||
const prefix = breakpoint ? `${breakpoint}:` : '';
|
||||
return `${prefix}grid-cols-${cols}`;
|
||||
};
|
||||
|
||||
// Helper function to get span classes
|
||||
const getSpanClasses = (span: GridCols | undefined, type: 'col' | 'row', breakpoint: string = '') => {
|
||||
if (!span) return '';
|
||||
const prefix = breakpoint ? `${breakpoint}:` : '';
|
||||
const typePrefix = type === 'col' ? 'col' : 'row';
|
||||
return `${prefix}${typePrefix}-span-${span}`;
|
||||
};
|
||||
|
||||
// Helper function to get responsive column classes
|
||||
const getResponsiveColClasses = (responsiveCols: GridProps['responsiveCols']) => {
|
||||
if (!responsiveCols) return '';
|
||||
|
||||
let classes = '';
|
||||
|
||||
// Mobile (default)
|
||||
if (responsiveCols.mobile) {
|
||||
classes += `grid-cols-${responsiveCols.mobile} `;
|
||||
}
|
||||
|
||||
// Tablet
|
||||
if (responsiveCols.tablet) {
|
||||
classes += `md:grid-cols-${responsiveCols.tablet} `;
|
||||
}
|
||||
|
||||
// Desktop
|
||||
if (responsiveCols.desktop) {
|
||||
classes += `lg:grid-cols-${responsiveCols.desktop} `;
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
// Main Grid Component
|
||||
export const Grid = forwardRef<HTMLDivElement, GridProps>(
|
||||
(
|
||||
{
|
||||
cols = 1,
|
||||
gap = 'md',
|
||||
colsSm,
|
||||
colsMd,
|
||||
colsLg,
|
||||
colsXl,
|
||||
alignItems,
|
||||
justifyItems,
|
||||
className = '',
|
||||
children,
|
||||
stackMobile = false,
|
||||
responsiveCols,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Get responsive column configuration
|
||||
const getResponsiveColumns = () => {
|
||||
if (responsiveCols) {
|
||||
return getResponsiveColClasses(responsiveCols);
|
||||
}
|
||||
|
||||
if (stackMobile) {
|
||||
// Mobile-first: 1 column, then scale up
|
||||
return `grid-cols-1 sm:grid-cols-2 ${colsMd ? `md:grid-cols-${colsMd}` : 'md:grid-cols-3'} ${colsLg ? `lg:grid-cols-${colsLg}` : ''}`;
|
||||
}
|
||||
|
||||
// Default responsive behavior
|
||||
let colClasses = `grid-cols-${cols}`;
|
||||
if (colsSm) colClasses += ` sm:grid-cols-${colsSm}`;
|
||||
if (colsMd) colClasses += ` md:grid-cols-${colsMd}`;
|
||||
if (colsLg) colClasses += ` lg:grid-cols-${colsLg}`;
|
||||
if (colsXl) colClasses += ` xl:grid-cols-${colsXl}`;
|
||||
|
||||
return colClasses;
|
||||
};
|
||||
|
||||
// Get responsive gap
|
||||
const getResponsiveGap = () => {
|
||||
if (gap === 'responsive') {
|
||||
return 'gap-2 xs:gap-3 sm:gap-4 md:gap-6 lg:gap-8';
|
||||
}
|
||||
|
||||
// Mobile-first gap scaling
|
||||
if (stackMobile) {
|
||||
return 'gap-3 sm:gap-4 md:gap-6 lg:gap-8';
|
||||
}
|
||||
|
||||
return getGapStyles(gap);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base grid
|
||||
'grid',
|
||||
// Responsive columns
|
||||
getResponsiveColumns(),
|
||||
// Gap (responsive)
|
||||
getResponsiveGap(),
|
||||
// Alignment
|
||||
alignItems && `items-${alignItems}`,
|
||||
justifyItems && `justify-items-${justifyItems}`,
|
||||
// Mobile-specific: ensure full width
|
||||
'w-full',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
// Add role for accessibility
|
||||
role="grid"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Grid.displayName = 'Grid';
|
||||
|
||||
// Grid Item Component
|
||||
export const GridItem = forwardRef<HTMLDivElement, GridItemProps>(
|
||||
(
|
||||
{
|
||||
colSpan,
|
||||
colSpanSm,
|
||||
colSpanMd,
|
||||
colSpanLg,
|
||||
colSpanXl,
|
||||
rowSpan,
|
||||
rowSpanSm,
|
||||
rowSpanMd,
|
||||
rowSpanLg,
|
||||
rowSpanXl,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Column spans
|
||||
getSpanClasses(colSpan, 'col'),
|
||||
getSpanClasses(colSpanSm, 'col', 'sm'),
|
||||
getSpanClasses(colSpanMd, 'col', 'md'),
|
||||
getSpanClasses(colSpanLg, 'col', 'lg'),
|
||||
getSpanClasses(colSpanXl, 'col', 'xl'),
|
||||
// Row spans
|
||||
getSpanClasses(rowSpan, 'row'),
|
||||
getSpanClasses(rowSpanSm, 'row', 'sm'),
|
||||
getSpanClasses(rowSpanMd, 'row', 'md'),
|
||||
getSpanClasses(rowSpanLg, 'row', 'lg'),
|
||||
getSpanClasses(rowSpanXl, 'row', 'xl'),
|
||||
// Ensure item doesn't overflow
|
||||
'min-w-0',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
// Add role for accessibility
|
||||
role="gridcell"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GridItem.displayName = 'GridItem';
|
||||
|
||||
// Export types for external use
|
||||
export type { GridProps, GridItemProps, GridCols, GridGap };
|
||||
@@ -1,255 +0,0 @@
|
||||
'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;
|
||||
@@ -1,224 +0,0 @@
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Loading sizes
|
||||
type LoadingSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
// Loading variants
|
||||
type LoadingVariant = 'primary' | 'secondary' | 'neutral' | 'contrast';
|
||||
|
||||
// Loading props interface
|
||||
interface LoadingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: LoadingSize;
|
||||
variant?: LoadingVariant;
|
||||
overlay?: boolean;
|
||||
text?: string;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to get size styles
|
||||
const getSizeStyles = (size: LoadingSize) => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'w-4 h-4 border-2';
|
||||
case 'md':
|
||||
return 'w-8 h-8 border-4';
|
||||
case 'lg':
|
||||
return 'w-12 h-12 border-4';
|
||||
case 'xl':
|
||||
return 'w-16 h-16 border-4';
|
||||
default:
|
||||
return 'w-8 h-8 border-4';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get variant styles
|
||||
const getVariantStyles = (variant: LoadingVariant) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'border-primary';
|
||||
case 'secondary':
|
||||
return 'border-secondary';
|
||||
case 'neutral':
|
||||
return 'border-gray-300';
|
||||
case 'contrast':
|
||||
return 'border-white';
|
||||
default:
|
||||
return 'border-primary';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get text size
|
||||
const getTextSize = (size: LoadingSize) => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'text-sm';
|
||||
case 'md':
|
||||
return 'text-base';
|
||||
case 'lg':
|
||||
return 'text-lg';
|
||||
case 'xl':
|
||||
return 'text-xl';
|
||||
default:
|
||||
return 'text-base';
|
||||
}
|
||||
};
|
||||
|
||||
// Main Loading Component
|
||||
export const Loading = forwardRef<HTMLDivElement, LoadingProps>(
|
||||
({
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
overlay = false,
|
||||
text,
|
||||
fullscreen = false,
|
||||
className = '',
|
||||
...props
|
||||
}, ref) => {
|
||||
const spinner = (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full',
|
||||
'border-t-transparent',
|
||||
getSizeStyles(size),
|
||||
getVariantStyles(variant),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (overlay) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex items-center justify-center',
|
||||
'bg-black/50 backdrop-blur-sm',
|
||||
fullscreen && 'w-screen h-screen'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{spinner}
|
||||
{text && (
|
||||
<span className={cn('text-white font-medium', getTextSize(size))}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
fullscreen && 'w-screen h-screen'
|
||||
)}
|
||||
>
|
||||
{spinner}
|
||||
{text && (
|
||||
<span className={cn('text-gray-700 font-medium', getTextSize(size))}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Loading.displayName = 'Loading';
|
||||
|
||||
// Loading Button Component
|
||||
interface LoadingButtonProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: LoadingSize;
|
||||
variant?: LoadingVariant;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export const LoadingButton = forwardRef<HTMLDivElement, LoadingButtonProps>(
|
||||
({ size = 'md', variant = 'primary', text = 'Loading...', className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 rounded-lg',
|
||||
'bg-gray-100 text-gray-700',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-t-transparent',
|
||||
getSizeStyles(size === 'sm' ? 'sm' : 'md'),
|
||||
getVariantStyles(variant)
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LoadingButton.displayName = 'LoadingButton';
|
||||
|
||||
// Loading Skeleton Component
|
||||
interface LoadingSkeletonProps extends HTMLAttributes<HTMLDivElement> {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
rounded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingSkeleton = forwardRef<HTMLDivElement, LoadingSkeletonProps>(
|
||||
({ width = '100%', height = '1rem', rounded = false, className = '', ...props }, ref) => {
|
||||
// Convert numeric values to Tailwind width classes
|
||||
const getWidthClass = (width: string | number) => {
|
||||
if (typeof width === 'number') {
|
||||
if (width <= 32) return 'w-8';
|
||||
if (width <= 64) return 'w-16';
|
||||
if (width <= 128) return 'w-32';
|
||||
if (width <= 192) return 'w-48';
|
||||
if (width <= 256) return 'w-64';
|
||||
return 'w-full';
|
||||
}
|
||||
return width === '100%' ? 'w-full' : width;
|
||||
};
|
||||
|
||||
// Convert numeric values to Tailwind height classes
|
||||
const getHeightClass = (height: string | number) => {
|
||||
if (typeof height === 'number') {
|
||||
if (height <= 8) return 'h-2';
|
||||
if (height <= 16) return 'h-4';
|
||||
if (height <= 24) return 'h-6';
|
||||
if (height <= 32) return 'h-8';
|
||||
if (height <= 48) return 'h-12';
|
||||
if (height <= 64) return 'h-16';
|
||||
return 'h-auto';
|
||||
}
|
||||
return height === '1rem' ? 'h-4' : height;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'animate-pulse bg-gray-200',
|
||||
rounded && 'rounded-md',
|
||||
getWidthClass(width),
|
||||
getHeightClass(height),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LoadingSkeleton.displayName = 'LoadingSkeleton';
|
||||
|
||||
// Export types for external use
|
||||
export type { LoadingProps, LoadingSize, LoadingVariant, LoadingButtonProps, LoadingSkeletonProps };
|
||||
@@ -1,367 +0,0 @@
|
||||
# UI Components
|
||||
|
||||
A comprehensive design system of reusable UI components for the KLZ Cables Next.js application. Built with TypeScript, Tailwind CSS, and accessibility best practices.
|
||||
|
||||
## Overview
|
||||
|
||||
This component library provides building blocks for creating consistent, responsive, and accessible user interfaces. All components are fully typed with TypeScript and follow modern web standards.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Button Component
|
||||
|
||||
A versatile button component with multiple variants, sizes, and states.
|
||||
|
||||
**Features:**
|
||||
- Multiple variants: `primary`, `secondary`, `outline`, `ghost`
|
||||
- Three sizes: `sm`, `md`, `lg`
|
||||
- Icon support with left/right positioning
|
||||
- Loading state with spinner
|
||||
- Full width option
|
||||
- Proper accessibility attributes
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
// Basic usage
|
||||
<Button variant="primary" size="md">Click me</Button>
|
||||
|
||||
// With icon
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<ArrowRightIcon />}
|
||||
iconPosition="right"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
// Loading state
|
||||
<Button variant="primary" loading>Processing...</Button>
|
||||
|
||||
// Disabled
|
||||
<Button variant="primary" disabled>Not available</Button>
|
||||
|
||||
// Full width
|
||||
<Button variant="primary" fullWidth>Submit</Button>
|
||||
```
|
||||
|
||||
### 2. Card Component
|
||||
|
||||
Flexible container component with optional header, body, footer, and image sections.
|
||||
|
||||
**Features:**
|
||||
- Three variants: `elevated`, `flat`, `bordered`
|
||||
- Optional padding: `none`, `sm`, `md`, `lg`, `xl`
|
||||
- Hover effects
|
||||
- Image support (top or background)
|
||||
- Composable sub-components
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Card, CardHeader, CardBody, CardFooter, CardImage } from '@/components/ui';
|
||||
|
||||
// Basic card
|
||||
<Card variant="elevated" padding="md">
|
||||
<CardHeader title="Title" subtitle="Subtitle" />
|
||||
<CardBody>
|
||||
<p>Card content goes here</p>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button variant="primary">Action</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
// Card with image
|
||||
<Card variant="flat" padding="none">
|
||||
<CardImage
|
||||
src="/image.jpg"
|
||||
alt="Description"
|
||||
height="md"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<CardHeader title="Image Card" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
// Hoverable card
|
||||
<Card variant="elevated" hoverable>
|
||||
{/* content */}
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 3. Container Component
|
||||
|
||||
Responsive wrapper for centering content with configurable max-width and padding.
|
||||
|
||||
**Features:**
|
||||
- Max-width options: `xs` to `6xl`, `full`
|
||||
- Padding options: `none`, `sm`, `md`, `lg`, `xl`, `2xl`
|
||||
- Centering option
|
||||
- Fluid mode (full width)
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Container } from '@/components/ui';
|
||||
|
||||
// Standard container
|
||||
<Container maxWidth="6xl" padding="lg">
|
||||
<YourContent />
|
||||
</Container>
|
||||
|
||||
// Medium container
|
||||
<Container maxWidth="md" padding="md">
|
||||
<YourContent />
|
||||
</Container>
|
||||
|
||||
// Fluid (full width)
|
||||
<Container fluid>
|
||||
<YourContent />
|
||||
</Container>
|
||||
|
||||
// Without centering
|
||||
<Container maxWidth="lg" centered={false}>
|
||||
<YourContent />
|
||||
</Container>
|
||||
```
|
||||
|
||||
### 4. Grid Component
|
||||
|
||||
Responsive grid system with configurable columns and gaps.
|
||||
|
||||
**Features:**
|
||||
- 1-12 column system
|
||||
- Responsive breakpoints: `colsSm`, `colsMd`, `colsLg`, `colsXl`
|
||||
- Gap spacing: `none`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`
|
||||
- Grid item span control
|
||||
- Alignment options
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Grid, GridItem } from '@/components/ui';
|
||||
|
||||
// Basic grid
|
||||
<Grid cols={3} gap="md">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</Grid>
|
||||
|
||||
// Responsive grid
|
||||
<Grid cols={1} colsMd={2} colsLg={4} gap="lg">
|
||||
{/* items */}
|
||||
</Grid>
|
||||
|
||||
// Grid with spans
|
||||
<Grid cols={3} gap="md">
|
||||
<GridItem colSpan={2}>Spans 2 columns</GridItem>
|
||||
<GridItem>1 column</GridItem>
|
||||
</GridItem>
|
||||
|
||||
// Grid with alignment
|
||||
<Grid cols={2} gap="md" alignItems="center" justifyItems="center">
|
||||
{/* items */}
|
||||
</Grid>
|
||||
```
|
||||
|
||||
### 5. Badge Component
|
||||
|
||||
Small status or label component with multiple variants.
|
||||
|
||||
**Features:**
|
||||
- Color variants: `primary`, `secondary`, `success`, `warning`, `error`, `info`, `neutral`
|
||||
- Sizes: `sm`, `md`, `lg`
|
||||
- Icon support with positioning
|
||||
- Rounded or squared
|
||||
- Badge group for multiple badges
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Badge, BadgeGroup } from '@/components/ui';
|
||||
|
||||
// Basic badge
|
||||
<Badge variant="success">Active</Badge>
|
||||
|
||||
// With icon
|
||||
<Badge variant="primary" icon={<StarIcon />}>Featured</Badge>
|
||||
|
||||
// Different sizes
|
||||
<Badge variant="warning" size="sm">Small</Badge>
|
||||
<Badge variant="warning" size="md">Medium</Badge>
|
||||
<Badge variant="warning" size="lg">Large</Badge>
|
||||
|
||||
// Badge group
|
||||
<BadgeGroup gap="sm">
|
||||
<Badge variant="primary">React</Badge>
|
||||
<Badge variant="secondary">TypeScript</Badge>
|
||||
<Badge variant="info">Tailwind</Badge>
|
||||
</BadgeGroup>
|
||||
|
||||
// Rounded
|
||||
<Badge variant="primary" rounded={true}>Rounded</Badge>
|
||||
```
|
||||
|
||||
### 6. Loading Component
|
||||
|
||||
Loading indicators including spinners, buttons, and skeletons.
|
||||
|
||||
**Features:**
|
||||
- Spinner sizes: `sm`, `md`, `lg`, `xl`
|
||||
- Spinner variants: `primary`, `secondary`, `neutral`, `contrast`
|
||||
- Optional text
|
||||
- Overlay mode with fullscreen option
|
||||
- Loading button
|
||||
- Skeleton loaders
|
||||
|
||||
**Usage:**
|
||||
```tsx
|
||||
import { Loading, LoadingButton, LoadingSkeleton } from '@/components/ui';
|
||||
|
||||
// Basic spinner
|
||||
<Loading size="md" />
|
||||
|
||||
// With text
|
||||
<Loading size="md" text="Loading data..." />
|
||||
|
||||
// Overlay (blocks UI)
|
||||
<Loading size="lg" overlay text="Please wait..." />
|
||||
|
||||
// Fullscreen overlay
|
||||
<Loading size="xl" overlay fullscreen text="Loading..." />
|
||||
|
||||
// Loading button
|
||||
<LoadingButton size="md" text="Processing..." />
|
||||
|
||||
// Skeleton loaders
|
||||
<LoadingSkeleton width="100%" height="1rem" />
|
||||
<LoadingSkeleton width="80%" height="1rem" rounded />
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
All components are fully typed with TypeScript. Props are exported for external use:
|
||||
|
||||
```tsx
|
||||
import type {
|
||||
ButtonProps,
|
||||
CardProps,
|
||||
ContainerProps,
|
||||
GridProps,
|
||||
BadgeProps,
|
||||
LoadingProps
|
||||
} from '@/components/ui';
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
All components include proper accessibility attributes:
|
||||
- ARIA labels where needed
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Screen reader friendly
|
||||
- Color contrast compliance
|
||||
|
||||
## Responsive Design
|
||||
|
||||
Components are built with mobile-first responsive design:
|
||||
- Tailwind responsive prefixes (`sm:`, `md:`, `lg:`, `xl:`)
|
||||
- Flexible layouts
|
||||
- Touch-friendly sizes
|
||||
- Adaptive spacing
|
||||
|
||||
## Customization
|
||||
|
||||
All components support the `className` prop for custom styling:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
variant="primary"
|
||||
className="custom-class hover:scale-105"
|
||||
>
|
||||
Custom Button
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use the index export:**
|
||||
```tsx
|
||||
import { Button, Card } from '@/components/ui';
|
||||
```
|
||||
|
||||
2. **Type your props:**
|
||||
```tsx
|
||||
interface MyComponentProps {
|
||||
title: string;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use semantic HTML:**
|
||||
- Buttons for actions
|
||||
- Links for navigation
|
||||
- Proper heading hierarchy
|
||||
|
||||
4. **Test with keyboard:**
|
||||
- Tab through all interactive elements
|
||||
- Enter/Space activates buttons
|
||||
- Escape closes modals
|
||||
|
||||
5. **Test with screen readers:**
|
||||
- Use VoiceOver (macOS) or NVDA (Windows)
|
||||
- Verify ARIA labels are descriptive
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Lazy load heavy components:**
|
||||
```tsx
|
||||
const HeavyComponent = dynamic(() => import('@/components/Heavy'), {
|
||||
loading: () => <Loading size="md" />
|
||||
});
|
||||
```
|
||||
|
||||
2. **Memoize expensive computations:**
|
||||
```tsx
|
||||
const memoizedValue = useMemo(() => computeExpensiveValue(), [deps]);
|
||||
```
|
||||
|
||||
3. **Use proper image optimization:**
|
||||
```tsx
|
||||
<CardImage
|
||||
src="/optimized-image.jpg"
|
||||
alt="Description"
|
||||
// Next.js Image component recommended for production
|
||||
/>
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Edge: Latest 2 versions
|
||||
- Firefox: Latest 2 versions
|
||||
- Safari: Latest 2 versions
|
||||
- Mobile browsers: iOS Safari, Chrome Mobile
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Modal component
|
||||
- [ ] Dropdown component
|
||||
- [ ] Tabs component
|
||||
- [ ] Accordion component
|
||||
- [ ] Tooltip component
|
||||
- [ ] Toast/Notification component
|
||||
- [ ] Form input components
|
||||
- [ ] Table component
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new components:
|
||||
1. Follow the existing component structure
|
||||
2. Use TypeScript interfaces
|
||||
3. Include proper accessibility attributes
|
||||
4. Add to the index export
|
||||
5. Update this documentation
|
||||
6. Test across browsers and devices
|
||||
|
||||
## License
|
||||
|
||||
Internal use only - KLZ Cables
|
||||
@@ -1,255 +0,0 @@
|
||||
'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;
|
||||
@@ -1,37 +0,0 @@
|
||||
// UI Components Export
|
||||
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './Button';
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardImage,
|
||||
type CardProps,
|
||||
type CardHeaderProps,
|
||||
type CardBodyProps,
|
||||
type CardFooterProps,
|
||||
type CardImageProps,
|
||||
type CardVariant
|
||||
} from './Card';
|
||||
export { Container, type ContainerProps } from './Container';
|
||||
export { Grid, GridItem, type GridProps, type GridItemProps, type GridCols, type GridGap } from './Grid';
|
||||
export {
|
||||
Badge,
|
||||
BadgeGroup,
|
||||
type BadgeProps,
|
||||
type BadgeVariant,
|
||||
type BadgeSize,
|
||||
type BadgeGroupProps
|
||||
} from './Badge';
|
||||
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