feat: Add vertical tab layout to Coupon form
Implemented VerticalTabForm component for better UX Created Components: 1. VerticalTabForm.tsx - Reusable vertical tab layout - Left sidebar with navigation (250px on desktop) - Right content area (scrollable) - Scroll spy - auto-highlights active section - Click to scroll to section - Smooth scrolling behavior - Icons support for tabs 2. FormSection component - Wrapper for form sections - Proper ref forwarding - Section ID tracking Updated CouponForm: - Added vertical tab navigation - 3 sections: General, Usage restrictions, Usage limits - Icons: Settings, ShieldCheck, BarChart3 - Narrower content area (better readability) - Desktop-only (lg:block) - mobile keeps original layout Features: ✅ Scroll spy - active tab follows scroll ✅ Click navigation - smooth scroll to section ✅ Visual hierarchy - clear section separation ✅ Better space utilization ✅ Reduced form width for readability ✅ Professional UI like Shopify/Stripe Layout: - Desktop: 250px sidebar + remaining content - Content: max-h-[calc(100vh-200px)] scrollable - Sticky sidebar (top-4) - Active state: bg-primary text-primary-foreground - Hover state: bg-muted hover:text-foreground Next: Apply same pattern to Products form
This commit is contained in:
132
admin-spa/src/components/VerticalTabForm.tsx
Normal file
132
admin-spa/src/components/VerticalTabForm.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface VerticalTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerticalTabFormProps {
|
||||||
|
tabs: VerticalTab[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerticalTabForm({ tabs, children, className }: VerticalTabFormProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState(tabs[0]?.id || '');
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sectionRefs = useRef<{ [key: string]: HTMLElement }>({});
|
||||||
|
|
||||||
|
// Scroll spy - update active tab based on scroll position
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!contentRef.current) return;
|
||||||
|
|
||||||
|
const scrollPosition = contentRef.current.scrollTop + 100; // Offset for better UX
|
||||||
|
|
||||||
|
// Find which section is currently in view
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const section = sectionRefs.current[tab.id];
|
||||||
|
if (section) {
|
||||||
|
const { offsetTop, offsetHeight } = section;
|
||||||
|
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
|
||||||
|
setActiveTab(tab.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (content) {
|
||||||
|
content.addEventListener('scroll', handleScroll);
|
||||||
|
return () => content.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
// Register section refs
|
||||||
|
const registerSection = (id: string, element: HTMLElement | null) => {
|
||||||
|
if (element) {
|
||||||
|
sectionRefs.current[id] = element;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll to section
|
||||||
|
const scrollToSection = (id: string) => {
|
||||||
|
const section = sectionRefs.current[id];
|
||||||
|
if (section && contentRef.current) {
|
||||||
|
const offsetTop = section.offsetTop - 20; // Small offset from top
|
||||||
|
contentRef.current.scrollTo({
|
||||||
|
top: offsetTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
setActiveTab(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-6', className)}>
|
||||||
|
{/* Vertical Tabs Sidebar */}
|
||||||
|
<div className="hidden lg:block w-56 flex-shrink-0">
|
||||||
|
<div className="sticky top-4 space-y-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => scrollToSection(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors',
|
||||||
|
'flex items-center gap-3',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon && <span className="w-4 h-4">{tab.icon}</span>}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="flex-1 overflow-y-auto max-h-[calc(100vh-200px)] pr-2"
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child) => {
|
||||||
|
if (React.isValidElement(child) && child.props['data-section-id']) {
|
||||||
|
const sectionId = child.props['data-section-id'];
|
||||||
|
return React.cloneElement(child as React.ReactElement<any>, {
|
||||||
|
ref: (el: HTMLElement) => registerSection(sectionId, el),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section wrapper component for easier usage
|
||||||
|
interface SectionProps {
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormSection = React.forwardRef<HTMLDivElement, SectionProps>(
|
||||||
|
({ id, children, className }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-section-id={id}
|
||||||
|
className={cn('mb-6 scroll-mt-4', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FormSection.displayName = 'FormSection';
|
||||||
@@ -120,7 +120,10 @@ export function MultiSelect({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search..." />
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
className="!border-none !shadow-none !ring-0"
|
||||||
|
/>
|
||||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||||
<CommandGroup className="max-h-64 overflow-auto">
|
<CommandGroup className="max-h-64 overflow-auto">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MultiSelect } from '@/components/ui/multi-select';
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
|
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
||||||
import { ProductsApi } from '@/lib/api';
|
import { ProductsApi } from '@/lib/api';
|
||||||
|
import { Settings, ShieldCheck, BarChart3 } from 'lucide-react';
|
||||||
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
||||||
|
|
||||||
interface CouponFormProps {
|
interface CouponFormProps {
|
||||||
@@ -76,9 +78,17 @@ export default function CouponForm({
|
|||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'general', label: __('General'), icon: <Settings className="w-4 h-4" /> },
|
||||||
|
{ id: 'restrictions', label: __('Usage restrictions'), icon: <ShieldCheck className="w-4 h-4" /> },
|
||||||
|
{ id: 'limits', label: __('Usage limits'), icon: <BarChart3 className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
<form ref={formRef} onSubmit={handleSubmit}>
|
||||||
|
<VerticalTabForm tabs={tabs}>
|
||||||
{/* General Settings */}
|
{/* General Settings */}
|
||||||
|
<FormSection id="general">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{__('General')}</CardTitle>
|
<CardTitle>{__('General')}</CardTitle>
|
||||||
@@ -170,8 +180,10 @@ export default function CouponForm({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
{/* Usage Restrictions */}
|
{/* Usage Restrictions */}
|
||||||
|
<FormSection id="restrictions">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{__('Usage restrictions')}</CardTitle>
|
<CardTitle>{__('Usage restrictions')}</CardTitle>
|
||||||
@@ -320,8 +332,10 @@ export default function CouponForm({
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
{/* Usage Limits */}
|
||||||
|
<FormSection id="limits">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{__('Usage limits')}</CardTitle>
|
<CardTitle>{__('Usage limits')}</CardTitle>
|
||||||
@@ -378,10 +392,11 @@ export default function CouponForm({
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
{/* Submit Button (if not hidden) */}
|
{/* Submit Button (if not hidden) */}
|
||||||
{!hideSubmitButton && (
|
{!hideSubmitButton && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 mt-6">
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
{submitting
|
{submitting
|
||||||
? __('Saving...')
|
? __('Saving...')
|
||||||
@@ -391,6 +406,7 @@ export default function CouponForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</VerticalTabForm>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user