Files
WooNooW/admin-spa/src/routes/Customers/index.tsx
dwindown 97e24ae408 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!
2025-11-20 23:34:37 +07:00

317 lines
12 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { __ } from '@/lib/i18n';
import { CustomersApi, type Customer } from '@/lib/api/customers';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { useFABConfig } from '@/hooks/useFABConfig';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
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, ChevronRight } from 'lucide-react';
import { formatMoney } from '@/lib/currency';
export default function CustomersIndex() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// State
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// FAB config - 'none' because submenu has 'New' tab (per SOP)
useFABConfig('none');
// Fetch customers
const customersQuery = useQuery({
queryKey: ['customers', page, search],
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
await Promise.all(ids.map(id => CustomersApi.delete(id)));
},
onSuccess: () => {
showSuccessToast(__('Customers deleted successfully'));
setSelectedIds([]);
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
onError: (error: any) => {
showErrorToast(error);
},
});
// Handlers
const toggleSelection = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleAll = () => {
if (selectedIds.length === customers.length) {
setSelectedIds([]);
} else {
setSelectedIds(customers.map(c => c.id));
}
};
const handleDelete = () => {
if (selectedIds.length === 0) return;
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
deleteMutation.mutate(selectedIds);
};
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
};
// Data
const customers = customersQuery.data?.data || [];
const pagination = customersQuery.data?.pagination;
// Loading state
if (customersQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
// Error state
if (customersQuery.isError) {
return (
<ErrorCard
title={__('Failed to load customers')}
message={getPageLoadErrorMessage(customersQuery.error)}
onRetry={() => customersQuery.refetch()}
/>
);
}
return (
<div className="space-y-4">
{/* Mobile: Search */}
<div className="md:hidden">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="search"
placeholder={__('Search customers...')}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="pl-9"
/>
</div>
</div>
{/* Desktop: Toolbar */}
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
{/* Left: Bulk Actions */}
<div className="flex gap-3">
{selectedIds.length > 0 && (
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{__('Delete')} ({selectedIds.length})
</button>
)}
<button
onClick={handleRefresh}
disabled={customersQuery.isFetching}
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${customersQuery.isFetching ? 'animate-spin' : ''}`} />
{__('Refresh')}
</button>
</div>
{/* Right: Search */}
<div className="flex gap-3 items-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="search"
placeholder={__('Search customers...')}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="pl-9 w-64"
/>
</div>
</div>
</div>
</div>
{/* Desktop: Table */}
<div className="hidden md:block rounded-lg border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50">
<tr className="border-b">
<th className="w-12 p-3">
<Checkbox
checked={selectedIds.length === customers.length && customers.length > 0}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</th>
<th className="text-left p-3 font-medium">{__('Customer')}</th>
<th className="text-left p-3 font-medium">{__('Email')}</th>
<th className="text-left p-3 font-medium">{__('Orders')}</th>
<th className="text-left p-3 font-medium">{__('Total Spent')}</th>
<th className="text-left p-3 font-medium">{__('Registered')}</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-2 opacity-50" />
{search ? __('No customers found matching your search') : __('No customers yet')}
{!search && (
<p className="text-sm mt-1">
<Link to="/customers/new" className="text-primary hover:underline">
{__('Create your first customer')}
</Link>
</p>
)}
</td>
</tr>
) : (
customers.map((customer) => (
<tr key={customer.id} className="border-b hover:bg-muted/30 last:border-0">
<td className="p-3">
<Checkbox
checked={selectedIds.includes(customer.id)}
onCheckedChange={() => toggleSelection(customer.id)}
aria-label={__('Select customer')}
/>
</td>
<td className="p-3">
<Link to={`/customers/${customer.id}/edit`} className="font-medium hover:underline">
{customer.display_name || `${customer.first_name} ${customer.last_name}`}
</Link>
</td>
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
<td className="p-3 text-sm">{customer.stats?.total_orders || 0}</td>
<td className="p-3 text-sm font-medium">
{customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{new Date(customer.registered).toLocaleDateString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Mobile: Cards */}
<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" />
{search ? __('No customers found') : __('No customers yet')}
</Card>
) : (
customers.map((customer) => (
<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">
{/* Line 1: Name */}
<h3 className="font-bold text-base leading-tight mb-1">
{customer.display_name || `${customer.first_name} ${customer.last_name}`}
</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>{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>
</Link>
))
)}
</div>
{/* Pagination */}
{pagination && pagination.total_pages > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1 || customersQuery.isFetching}
>
{__('Previous')}
</Button>
<span className="px-4 py-2 text-sm">
{__('Page')} {page} {__('of')} {pagination.total_pages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(pagination.total_pages, p + 1))}
disabled={page === pagination.total_pages || customersQuery.isFetching}
>
{__('Next')}
</Button>
</div>
)}
</div>
);
}