feat(ui): Make cards linkable and hide submenu on detail pages

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!
This commit is contained in:
dwindown
2025-11-20 23:34:37 +07:00
parent fe63e08239
commit 97e24ae408
6 changed files with 165 additions and 54 deletions

View File

@@ -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 (
<div data-submenubar className={`border-b border-border bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
<div data-submenubar className={`border-b border-border bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60 ${hiddenOnMobile}`}>
<div className="px-4 py-2">
<div className="flex gap-2 overflow-x-auto no-scrollbar">
{items.map((it) => {

View File

@@ -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 (
<Link
to={`/coupons/${coupon.id}/edit`}
className="block bg-card border border-border rounded-xl p-3 hover:bg-accent/50 transition-colors active:scale-[0.98] active:transition-transform shadow-sm"
>
<div className="flex items-center gap-3">
{/* Checkbox */}
{onSelect && (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(coupon.id);
}}
>
<Checkbox
checked={selected}
aria-label={__('Select coupon')}
className="w-5 h-5"
/>
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{/* Line 1: Code with Badge */}
<div className="flex items-center gap-2 mb-2">
<div className="flex-shrink-0 p-2 rounded-xl bg-primary/10 text-primary flex items-center justify-center font-bold text-base">
<Tag className="w-4 h-4 mr-1" />
{coupon.code}
</div>
<Badge variant="outline" className="text-xs">
{formatDiscountType(coupon.discount_type)}
</Badge>
</div>
{/* Line 2: Description */}
{coupon.description && (
<div className="text-sm text-muted-foreground truncate mb-2">
{coupon.description}
</div>
)}
{/* Line 3: Usage & Expiry */}
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-2">
<span>
{__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'}
</span>
{coupon.date_expires && (
<span>
{__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')}
</span>
)}
</div>
{/* Line 4: Amount */}
<div className="font-bold text-lg tabular-nums text-primary">
{formatAmount()}
</div>
</div>
{/* Chevron */}
<ChevronRight className="w-5 h-5 text-muted-foreground flex-shrink-0" />
</div>
</Link>
);
}

View File

@@ -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() {
<td className="p-3 text-center">
<button
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
onClick={() => navigate(`/coupons/${coupon.id}`)}
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
>
<Edit className="w-4 h-4" />
{__('Edit')}
@@ -295,7 +296,7 @@ export default function CouponsIndex() {
</div>
{/* Mobile Cards */}
<div className="md:hidden space-y-2">
<div className="md:hidden space-y-3">
{coupons.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
@@ -306,39 +307,12 @@ export default function CouponsIndex() {
</Card>
) : (
coupons.map((coupon) => (
<Card key={coupon.id} className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedIds.includes(coupon.id)}
onCheckedChange={() => toggleSelection(coupon.id)}
/>
<div>
<div className="font-medium">{coupon.code}</div>
<Badge variant="outline" className="mt-1">{formatDiscountType(coupon.discount_type)}</Badge>
</div>
</div>
<div className="text-right">
<div className="font-medium">{formatAmount(coupon)}</div>
</div>
</div>
{coupon.description && (
<p className="text-sm text-muted-foreground mb-2">{coupon.description}</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div>{__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'}</div>
{coupon.date_expires && (
<div>{__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')}</div>
)}
</div>
<button
className="mt-3 w-full inline-flex items-center justify-center gap-2 text-sm text-blue-600 hover:text-blue-700"
onClick={() => navigate(`/coupons/${coupon.id}`)}
>
<Edit className="w-4 h-4" />
{__('Edit')}
</button>
</Card>
<CouponCard
key={coupon.id}
coupon={coupon}
selected={selectedIds.includes(coupon.id)}
onSelect={toggleSelection}
/>
))
)}
</div>

View File

@@ -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() {
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-2">
<div className="md:hidden space-y-3">
{customers.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-2 opacity-50" />
@@ -234,26 +234,55 @@ export default function CustomersIndex() {
</Card>
) : (
customers.map((customer) => (
<Card key={customer.id} className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedIds.includes(customer.id)}
onCheckedChange={() => toggleSelection(customer.id)}
/>
<Link
key={customer.id}
to={`/customers/${customer.id}/edit`}
className="block bg-card border border-border rounded-xl p-3 hover:bg-accent/50 transition-colors active:scale-[0.98] active:transition-transform shadow-sm"
>
<div className="flex items-center gap-3">
{/* Checkbox */}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleSelection(customer.id);
}}
>
<Checkbox
checked={selectedIds.includes(customer.id)}
aria-label={__('Select customer')}
className="w-5 h-5"
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<Link to={`/customers/${customer.id}/edit`} className="font-medium hover:underline block">
{/* Line 1: Name */}
<h3 className="font-bold text-base leading-tight mb-1">
{customer.display_name || `${customer.first_name} ${customer.last_name}`}
</Link>
<p className="text-sm text-muted-foreground truncate">{customer.email}</p>
<div className="flex gap-4 mt-2 text-sm">
</h3>
{/* Line 2: Email */}
<div className="text-sm text-muted-foreground truncate mb-2">
{customer.email}
</div>
{/* Line 3: Stats */}
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-1">
<span>{customer.stats?.total_orders || 0} {__('orders')}</span>
<span className="font-medium">
{customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'}
</span>
<span>{new Date(customer.registered).toLocaleDateString()}</span>
</div>
{/* Line 4: Total Spent */}
<div className="font-bold text-lg tabular-nums text-primary">
{customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'}
</div>
</div>
{/* Chevron */}
<ChevronRight className="w-5 h-5 text-muted-foreground flex-shrink-0" />
</div>
</Card>
</Link>
))
)}
</div>

View File

@@ -29,7 +29,7 @@ export function ProductCard({ product, selected, onSelect }: ProductCardProps) {
return (
<Link
to={`/products/${product.id}`}
to={`/products/${product.id}/edit`}
className="block bg-card border border-border rounded-xl p-3 hover:bg-accent/50 transition-colors active:scale-[0.98] active:transition-transform shadow-sm"
>
<div className="flex items-center gap-3">

View File

@@ -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 };