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:
dwindown
2025-11-20 16:00:03 +07:00
parent 0f47c08b7a
commit 7455d99ab8
3 changed files with 169 additions and 18 deletions

View 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';

View File

@@ -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) => (

View File

@@ -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,10 +78,18 @@ 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}>
{/* General Settings */} <VerticalTabForm tabs={tabs}>
<Card> {/* General Settings */}
<FormSection id="general">
<Card>
<CardHeader> <CardHeader>
<CardTitle>{__('General')}</CardTitle> <CardTitle>{__('General')}</CardTitle>
</CardHeader> </CardHeader>
@@ -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,19 +392,21 @@ 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...')
: mode === 'create' : mode === 'create'
? __('Create Coupon') ? __('Create Coupon')
: __('Update Coupon')} : __('Update Coupon')}
</Button> </Button>
</div> </div>
)} )}
</VerticalTabForm>
</form> </form>
); );
} }