From 97e24ae40894e33adfaaeaf8658db7415c60547d Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 20 Nov 2025 23:34:37 +0700 Subject: [PATCH] feat(ui): Make cards linkable and hide submenu on detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved mobile UX matching Orders/Products pattern Issue 1: Coupons and Customers cards not linkable ❌ Cards had separate checkbox and edit button ❌ Inconsistent with Orders/Products beautiful card design ❌ Less intuitive UX (extra tap required) Issue 2: Submenu showing on detail/new/edit pages ❌ Submenu tabs visible on mobile detail/new/edit pages ❌ Distracting and annoying (user feedback) ❌ Redundant (page has own tabs + back button) Changes Made: 1. Created CouponCard Component: ✅ Linkable card matching OrderCard/ProductCard pattern ✅ Whole card is tappable (better mobile UX) ✅ Checkbox with stopPropagation for selection ✅ Chevron icon indicating it's tappable ✅ Beautiful layout: Badge + Description + Usage + Amount ✅ Active scale animation on tap ✅ Hover effects 2. Updated Coupons/index.tsx: ✅ Replaced old card structure with CouponCard ✅ Fixed desktop edit link: /coupons/${id} → /coupons/${id}/edit ✅ Changed spacing: space-y-2 → space-y-3 (consistent with Orders) ✅ Cleaner, more maintainable code 3. Updated Customers/index.tsx: ✅ Made cards linkable (whole card is Link) ✅ Added ChevronRight icon ✅ Checkbox with stopPropagation ✅ Better layout: Name + Email + Stats + Total Spent ✅ Changed spacing: space-y-2 → space-y-3 ✅ Matches Orders/Products card design 4. Updated SubmenuBar.tsx: ✅ Hide on mobile for detail/new/edit pages ✅ Show on desktop (still useful for navigation) ✅ Regex pattern: /\/(orders|products|coupons|customers)\/(?:new|\d+(?:\/edit)?)$/ ✅ Applied via: hidden md:block class Card Pattern Comparison: Before (Coupons/Customers): After (All modules): Submenu Behavior: Mobile: - Index pages: ✅ Show submenu [All | New] - Detail/New/Edit: ❌ Hide submenu (has own tabs + back button) Desktop: - All pages: ✅ Show submenu (useful for quick navigation) Benefits: ✅ Consistent UX across all modules ✅ Better mobile experience (fewer taps) ✅ Less visual clutter on detail pages ✅ Cleaner, more intuitive navigation ✅ Matches industry standards (Shopify, WooCommerce) Result: Mobile UX now matches the beautiful Orders/Products design! --- admin-spa/src/components/nav/SubmenuBar.tsx | 6 +- .../routes/Coupons/components/CouponCard.tsx | 104 ++++++++++++++++++ admin-spa/src/routes/Coupons/index.tsx | 44 ++------ admin-spa/src/routes/Customers/index.tsx | 61 +++++++--- .../Products/components/ProductCard.tsx | 2 +- admin-spa/src/routes/Products/index.tsx | 2 +- 6 files changed, 165 insertions(+), 54 deletions(-) create mode 100644 admin-spa/src/routes/Coupons/components/CouponCard.tsx diff --git a/admin-spa/src/components/nav/SubmenuBar.tsx b/admin-spa/src/components/nav/SubmenuBar.tsx index cc41394..161d363 100644 --- a/admin-spa/src/components/nav/SubmenuBar.tsx +++ b/admin-spa/src/components/nav/SubmenuBar.tsx @@ -11,13 +11,17 @@ export default function SubmenuBar({ items = [], fullscreen = false, headerVisib // Single source of truth: props.items. No fallbacks, no demos, no path-based defaults if (items.length === 0) return null; + // Hide submenu on mobile for detail/new/edit pages (only show on index) + const isDetailPage = /\/(orders|products|coupons|customers)\/(?:new|\d+(?:\/edit)?)$/.test(pathname); + const hiddenOnMobile = isDetailPage ? 'hidden md:block' : ''; + // Calculate top position based on fullscreen state // Fullscreen: top-0 (no contextual headers, submenu is first element) // Normal: top-[calc(7rem+32px)] (below WP admin bar + menu bar) const topClass = fullscreen ? 'top-0' : 'top-[calc(7rem+32px)]'; return ( -
+
{items.map((it) => { diff --git a/admin-spa/src/routes/Coupons/components/CouponCard.tsx b/admin-spa/src/routes/Coupons/components/CouponCard.tsx new file mode 100644 index 0000000..f2d7b1c --- /dev/null +++ b/admin-spa/src/routes/Coupons/components/CouponCard.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, Tag } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import type { Coupon } from '@/lib/api/coupons'; + +interface CouponCardProps { + coupon: Coupon; + selected?: boolean; + onSelect?: (id: number) => void; +} + +export function CouponCard({ coupon, selected, onSelect }: CouponCardProps) { + // Format discount type + const formatDiscountType = (type: string) => { + switch (type) { + case 'percent': + return __('Percentage'); + case 'fixed_cart': + return __('Fixed Cart'); + case 'fixed_product': + return __('Fixed Product'); + default: + return type; + } + }; + + // Format amount + const formatAmount = () => { + if (coupon.discount_type === 'percent') { + return `${coupon.amount}%`; + } + return `Rp${coupon.amount.toLocaleString('id-ID')}`; + }; + + return ( + +
+ {/* Checkbox */} + {onSelect && ( +
{ + e.preventDefault(); + e.stopPropagation(); + onSelect(coupon.id); + }} + > + +
+ )} + + {/* Content */} +
+ {/* Line 1: Code with Badge */} +
+
+ + {coupon.code} +
+ + {formatDiscountType(coupon.discount_type)} + +
+ + {/* Line 2: Description */} + {coupon.description && ( +
+ {coupon.description} +
+ )} + + {/* Line 3: Usage & Expiry */} +
+ + {__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'} + + {coupon.date_expires && ( + + {__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')} + + )} +
+ + {/* Line 4: Amount */} +
+ {formatAmount()} +
+
+ + {/* Chevron */} + +
+ + ); +} diff --git a/admin-spa/src/routes/Coupons/index.tsx b/admin-spa/src/routes/Coupons/index.tsx index 47dacde..9b6be04 100644 --- a/admin-spa/src/routes/Coupons/index.tsx +++ b/admin-spa/src/routes/Coupons/index.tsx @@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'; import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react'; import { useFABConfig } from '@/hooks/useFABConfig'; import { CouponFilterSheet } from './components/CouponFilterSheet'; +import { CouponCard } from './components/CouponCard'; export default function CouponsIndex() { const navigate = useNavigate(); @@ -281,7 +282,7 @@ export default function CouponsIndex() {
{/* Mobile Cards */} -
+
{coupons.length === 0 ? ( @@ -306,39 +307,12 @@ export default function CouponsIndex() { ) : ( coupons.map((coupon) => ( - -
-
- toggleSelection(coupon.id)} - /> -
-
{coupon.code}
- {formatDiscountType(coupon.discount_type)} -
-
-
-
{formatAmount(coupon)}
-
-
- {coupon.description && ( -

{coupon.description}

- )} -
-
{__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'}
- {coupon.date_expires && ( -
{__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')}
- )} -
- -
+ )) )}
diff --git a/admin-spa/src/routes/Customers/index.tsx b/admin-spa/src/routes/Customers/index.tsx index d964097..21c4058 100644 --- a/admin-spa/src/routes/Customers/index.tsx +++ b/admin-spa/src/routes/Customers/index.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { ErrorCard } from '@/components/ErrorCard'; import { Skeleton } from '@/components/ui/skeleton'; -import { RefreshCw, Trash2, Search, User } from 'lucide-react'; +import { RefreshCw, Trash2, Search, User, ChevronRight } from 'lucide-react'; import { formatMoney } from '@/lib/currency'; export default function CustomersIndex() { @@ -226,7 +226,7 @@ export default function CustomersIndex() {
{/* Mobile: Cards */} -
+
{customers.length === 0 ? ( @@ -234,26 +234,55 @@ export default function CustomersIndex() { ) : ( customers.map((customer) => ( - -
- toggleSelection(customer.id)} - /> + +
+ {/* Checkbox */} +
{ + e.preventDefault(); + e.stopPropagation(); + toggleSelection(customer.id); + }} + > + +
+ + {/* Content */}
- + {/* Line 1: Name */} +

{customer.display_name || `${customer.first_name} ${customer.last_name}`} - -

{customer.email}

-
+

+ + {/* Line 2: Email */} +
+ {customer.email} +
+ + {/* Line 3: Stats */} +
{customer.stats?.total_orders || 0} {__('orders')} - - {customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'} - + {new Date(customer.registered).toLocaleDateString()} +
+ + {/* Line 4: Total Spent */} +
+ {customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'}
+ + {/* Chevron */} +
- + )) )}
diff --git a/admin-spa/src/routes/Products/components/ProductCard.tsx b/admin-spa/src/routes/Products/components/ProductCard.tsx index 11a0b71..b475c7d 100644 --- a/admin-spa/src/routes/Products/components/ProductCard.tsx +++ b/admin-spa/src/routes/Products/components/ProductCard.tsx @@ -29,7 +29,7 @@ export function ProductCard({ product, selected, onSelect }: ProductCardProps) { return (
diff --git a/admin-spa/src/routes/Products/index.tsx b/admin-spa/src/routes/Products/index.tsx index 75b4f27..5de157d 100644 --- a/admin-spa/src/routes/Products/index.tsx +++ b/admin-spa/src/routes/Products/index.tsx @@ -137,7 +137,7 @@ export default function Products() { const deleteMutation = useMutation({ mutationFn: async (ids: number[]) => { const results = await Promise.allSettled( - ids.map(id => api.del(`/products/${id}`)) + ids.map(id => api.del(`/products/${id}/edit`)) ); const failed = results.filter(r => r.status === 'rejected').length; return { total: ids.length, failed };