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!
317 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|