From 7455d99ab81adb413d4be3d41320f89b910e6f0f Mon Sep 17 00:00:00 2001
From: dwindown
Date: Thu, 20 Nov 2025 16:00:03 +0700
Subject: [PATCH] feat: Add vertical tab layout to Coupon form
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
admin-spa/src/components/VerticalTabForm.tsx | 132 +++++++++++++++++++
admin-spa/src/components/ui/multi-select.tsx | 5 +-
admin-spa/src/routes/Coupons/CouponForm.tsx | 50 ++++---
3 files changed, 169 insertions(+), 18 deletions(-)
create mode 100644 admin-spa/src/components/VerticalTabForm.tsx
diff --git a/admin-spa/src/components/VerticalTabForm.tsx b/admin-spa/src/components/VerticalTabForm.tsx
new file mode 100644
index 0000000..9e33e4e
--- /dev/null
+++ b/admin-spa/src/components/VerticalTabForm.tsx
@@ -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(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 (
+
+ {/* Vertical Tabs Sidebar */}
+
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+
+ {/* Content Area */}
+
+ {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
, {
+ ref: (el: HTMLElement) => registerSection(sectionId, el),
+ });
+ }
+ return child;
+ })}
+
+
+ );
+}
+
+// Section wrapper component for easier usage
+interface SectionProps {
+ id: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const FormSection = React.forwardRef(
+ ({ id, children, className }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+FormSection.displayName = 'FormSection';
diff --git a/admin-spa/src/components/ui/multi-select.tsx b/admin-spa/src/components/ui/multi-select.tsx
index 67b65dd..61580d1 100644
--- a/admin-spa/src/components/ui/multi-select.tsx
+++ b/admin-spa/src/components/ui/multi-select.tsx
@@ -120,7 +120,10 @@ export function MultiSelect({
-
+
{emptyMessage}
{options.map((option) => (
diff --git a/admin-spa/src/routes/Coupons/CouponForm.tsx b/admin-spa/src/routes/Coupons/CouponForm.tsx
index 1217bb6..f0b5821 100644
--- a/admin-spa/src/routes/Coupons/CouponForm.tsx
+++ b/admin-spa/src/routes/Coupons/CouponForm.tsx
@@ -9,7 +9,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { MultiSelect } from '@/components/ui/multi-select';
+import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
import { ProductsApi } from '@/lib/api';
+import { Settings, ShieldCheck, BarChart3 } from 'lucide-react';
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
interface CouponFormProps {
@@ -76,10 +78,18 @@ export default function CouponForm({
setFormData(prev => ({ ...prev, [field]: value }));
};
+ const tabs = [
+ { id: 'general', label: __('General'), icon: },
+ { id: 'restrictions', label: __('Usage restrictions'), icon: },
+ { id: 'limits', label: __('Usage limits'), icon: },
+ ];
+
return (
-
+
- {/* Usage Limits */}
+ {/* Usage Limits */}
+
{__('Usage limits')}
@@ -378,19 +392,21 @@ export default function CouponForm({
+
- {/* Submit Button (if not hidden) */}
- {!hideSubmitButton && (
-
-
-
- )}
+ {/* Submit Button (if not hidden) */}
+ {!hideSubmitButton && (
+
+
+
+ )}
+
);
}