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!
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import { Filter, Package, Trash2, RefreshCw } from 'lucide-react';
|
|
import { ErrorCard } from '@/components/ErrorCard';
|
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
|
import { __ } from '@/lib/i18n';
|
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { setQuery, getQuery } from '@/lib/query-params';
|
|
import { ProductCard } from './components/ProductCard';
|
|
import { FilterBottomSheet } from './components/FilterBottomSheet';
|
|
import { SearchBar } from './components/SearchBar';
|
|
|
|
const stockStatusStyle: Record<string, string> = {
|
|
instock: 'bg-emerald-100 text-emerald-800',
|
|
outofstock: 'bg-rose-100 text-rose-800',
|
|
onbackorder: 'bg-amber-100 text-amber-800',
|
|
};
|
|
|
|
function StockBadge({ value, quantity }: { value?: string; quantity?: number }) {
|
|
const v = (value || '').toLowerCase();
|
|
const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800';
|
|
const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v;
|
|
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
|
{label}
|
|
{quantity !== null && quantity !== undefined && ` (${quantity})`}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function Products() {
|
|
useFABConfig('products'); // Add FAB for creating products
|
|
const initial = getQuery();
|
|
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
|
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
|
const [type, setType] = useState<string | undefined>(initial.type || undefined);
|
|
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
|
|
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
|
|
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
|
|
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const perPage = 20;
|
|
const queryClient = useQueryClient();
|
|
|
|
React.useEffect(() => {
|
|
setQuery({ page, status, type, stock_status: stockStatus, category, orderby, order });
|
|
}, [page, status, type, stockStatus, category, orderby, order]);
|
|
|
|
const q = useQuery({
|
|
queryKey: ['products', { page, perPage, status, type, stock_status: stockStatus, category, orderby, order }],
|
|
queryFn: () => api.get('/products', {
|
|
page, per_page: perPage,
|
|
status,
|
|
type,
|
|
stock_status: stockStatus,
|
|
category,
|
|
orderby,
|
|
order,
|
|
}),
|
|
placeholderData: keepPreviousData,
|
|
staleTime: 0, // Always fetch fresh data
|
|
gcTime: 0, // Don't cache
|
|
});
|
|
|
|
// Fetch categories for filter
|
|
const categoriesQ = useQuery({
|
|
queryKey: ['product-categories'],
|
|
queryFn: () => api.get('/products/categories'),
|
|
});
|
|
|
|
const data = q.data as undefined | { rows: any[]; total: number; page: number; per_page: number };
|
|
const nav = useNavigate();
|
|
|
|
// Pull to refresh
|
|
const handleRefresh = useCallback(async () => {
|
|
setIsRefreshing(true);
|
|
await q.refetch();
|
|
setTimeout(() => setIsRefreshing(false), 500);
|
|
}, [q]);
|
|
|
|
// Filter products by search query
|
|
const filteredProducts = React.useMemo(() => {
|
|
const rows = data?.rows;
|
|
if (!rows) return [];
|
|
if (!searchQuery.trim()) return rows;
|
|
|
|
const query = searchQuery.toLowerCase();
|
|
return rows.filter((product: any) =>
|
|
product.name?.toLowerCase().includes(query) ||
|
|
product.sku?.toLowerCase().includes(query) ||
|
|
product.id?.toString().includes(query)
|
|
);
|
|
}, [data, searchQuery]);
|
|
|
|
// Count active filters
|
|
const activeFiltersCount = React.useMemo(() => {
|
|
let count = 0;
|
|
if (status) count++;
|
|
if (type) count++;
|
|
if (stockStatus) count++;
|
|
if (category) count++;
|
|
if (orderby !== 'date' || order !== 'desc') count++;
|
|
return count;
|
|
}, [status, type, stockStatus, category, orderby, order]);
|
|
|
|
// Bulk delete mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (ids: number[]) => {
|
|
const results = await Promise.allSettled(
|
|
ids.map(id => api.del(`/products/${id}/edit`))
|
|
);
|
|
const failed = results.filter(r => r.status === 'rejected').length;
|
|
return { total: ids.length, failed };
|
|
},
|
|
onSuccess: (result) => {
|
|
const { total, failed } = result;
|
|
if (failed === 0) {
|
|
toast.success(__('Products deleted successfully'));
|
|
} else if (failed < total) {
|
|
toast.warning(__(`${total - failed} products deleted, ${failed} failed`));
|
|
} else {
|
|
toast.error(__('Failed to delete products'));
|
|
}
|
|
setSelectedIds([]);
|
|
setShowDeleteDialog(false);
|
|
q.refetch();
|
|
},
|
|
onError: () => {
|
|
toast.error(__('Failed to delete products'));
|
|
setShowDeleteDialog(false);
|
|
},
|
|
});
|
|
|
|
// Checkbox handlers
|
|
const allIds = filteredProducts.map(r => r.id) || [];
|
|
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
|
|
const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length;
|
|
|
|
const toggleAll = () => {
|
|
if (allSelected) {
|
|
setSelectedIds([]);
|
|
} else {
|
|
setSelectedIds(allIds);
|
|
}
|
|
};
|
|
|
|
const toggleRow = (id: number) => {
|
|
setSelectedIds(prev =>
|
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
|
);
|
|
};
|
|
|
|
const handleDeleteClick = () => {
|
|
if (selectedIds.length > 0) {
|
|
setShowDeleteDialog(true);
|
|
}
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
deleteMutation.mutate(selectedIds);
|
|
};
|
|
|
|
const handleFiltersChange = (newFilters: any) => {
|
|
setPage(1);
|
|
setStatus(newFilters.status);
|
|
setType(newFilters.type);
|
|
setStockStatus(newFilters.stock_status);
|
|
setCategory(newFilters.category);
|
|
setOrderby(newFilters.orderby);
|
|
setOrder(newFilters.order);
|
|
};
|
|
|
|
const handleResetFilters = () => {
|
|
setStatus(undefined);
|
|
setType(undefined);
|
|
setStockStatus(undefined);
|
|
setCategory(undefined);
|
|
setOrderby('date');
|
|
setOrder('desc');
|
|
setPage(1);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 w-full pb-4">
|
|
{/* Mobile: Search + Filter */}
|
|
<div className="md:hidden">
|
|
<SearchBar
|
|
value={searchQuery}
|
|
onChange={setSearchQuery}
|
|
onFilterClick={() => setFilterSheetOpen(true)}
|
|
filterCount={activeFiltersCount}
|
|
/>
|
|
</div>
|
|
|
|
{/* Desktop: Top Bar with Filters */}
|
|
<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">
|
|
<div className="flex gap-3">
|
|
{selectedIds.length > 0 && (
|
|
<button
|
|
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"
|
|
onClick={handleDeleteClick}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
{__('Delete')} ({selectedIds.length})
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
|
onClick={handleRefresh}
|
|
disabled={q.isLoading || isRefreshing}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
{__('Refresh')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2 items-center">
|
|
<Filter className="min-w-4 w-4 h-4 opacity-60" />
|
|
{/* Status Filter */}
|
|
<Select value={status ?? 'all'} onValueChange={(v) => { setPage(1); setStatus(v === 'all' ? undefined : v); }}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue placeholder={__('Status')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="all">{__('All statuses')}</SelectItem>
|
|
<SelectItem value="publish">{__('Published')}</SelectItem>
|
|
<SelectItem value="draft">{__('Draft')}</SelectItem>
|
|
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
|
<SelectItem value="private">{__('Private')}</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Type Filter */}
|
|
<Select value={type ?? 'all'} onValueChange={(v) => { setPage(1); setType(v === 'all' ? undefined : v); }}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue placeholder={__('Type')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="all">{__('All types')}</SelectItem>
|
|
<SelectItem value="simple">{__('Simple')}</SelectItem>
|
|
<SelectItem value="variable">{__('Variable')}</SelectItem>
|
|
<SelectItem value="grouped">{__('Grouped')}</SelectItem>
|
|
<SelectItem value="external">{__('External')}</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Stock Status Filter */}
|
|
<Select value={stockStatus ?? 'all'} onValueChange={(v) => { setPage(1); setStockStatus(v === 'all' ? undefined : v); }}>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder={__('Stock')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="all">{__('All stock')}</SelectItem>
|
|
<SelectItem value="instock">{__('In Stock')}</SelectItem>
|
|
<SelectItem value="outofstock">{__('Out of Stock')}</SelectItem>
|
|
<SelectItem value="onbackorder">{__('On Backorder')}</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{activeFiltersCount > 0 && (
|
|
<button
|
|
onClick={handleResetFilters}
|
|
className="text-sm text-muted-foreground hover:text-foreground underline"
|
|
>
|
|
{__('Clear filters')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error State */}
|
|
{q.isError && (
|
|
<ErrorCard
|
|
title={__('Failed to load products')}
|
|
message={getPageLoadErrorMessage(q.error)}
|
|
onRetry={() => q.refetch()}
|
|
/>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{q.isLoading && (
|
|
<div className="space-y-2">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Skeleton key={i} className="h-20 w-full" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop: Table */}
|
|
{!q.isLoading && !q.isError && (
|
|
<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={allSelected}
|
|
ref={(el) => {
|
|
if (el) {
|
|
(el as any).indeterminate = someSelected && !allSelected;
|
|
}
|
|
}}
|
|
onCheckedChange={toggleAll}
|
|
aria-label={__('Select all')}
|
|
/>
|
|
</th>
|
|
<th className="text-left p-3 font-medium">{__('Product')}</th>
|
|
<th className="text-left p-3 font-medium">{__('SKU')}</th>
|
|
<th className="text-left p-3 font-medium">{__('Stock')}</th>
|
|
<th className="text-left p-3 font-medium">{__('Price')}</th>
|
|
<th className="text-left p-3 font-medium">{__('Type')}</th>
|
|
<th className="text-right p-3 font-medium">{__('Actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredProducts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
|
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
{searchQuery ? __('No products found matching your search') : __('No products found')}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredProducts.map((product: any) => (
|
|
<tr key={product.id} className="border-b hover:bg-muted/30">
|
|
<td className="p-3">
|
|
<Checkbox
|
|
checked={selectedIds.includes(product.id)}
|
|
onCheckedChange={() => toggleRow(product.id)}
|
|
aria-label={__('Select product')}
|
|
/>
|
|
</td>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-3">
|
|
{product.image_url ? (
|
|
<img src={product.image_url} alt={product.name} className="w-10 h-10 object-cover rounded border" />
|
|
) : (
|
|
<div className="w-10 h-10 bg-muted rounded border flex items-center justify-center">
|
|
<Package className="w-5 h-5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<Link to={`/products/${product.id}/edit`} className="font-medium hover:underline">
|
|
{product.name}
|
|
</Link>
|
|
</div>
|
|
</td>
|
|
<td className="p-3 font-mono text-sm">{product.sku || '—'}</td>
|
|
<td className="p-3">
|
|
{product.manage_stock ? (
|
|
<StockBadge value={product.stock_status} quantity={product.stock_quantity} />
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">∞</span>
|
|
)}
|
|
</td>
|
|
<td className="p-3">
|
|
{product.price_html ? (
|
|
<span dangerouslySetInnerHTML={{ __html: product.price_html }} />
|
|
) : product.sale_price ? (
|
|
<span className="space-x-1">
|
|
<span className="line-through text-muted-foreground text-sm">{formatMoney(product.regular_price)}</span>
|
|
<span className="text-emerald-600 font-medium">{formatMoney(product.sale_price)}</span>
|
|
</span>
|
|
) : product.regular_price ? (
|
|
<span>{formatMoney(product.regular_price)}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
<td className="p-3 text-sm">
|
|
<span className="capitalize px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs">
|
|
{product.type || 'simple'}
|
|
</span>
|
|
</td>
|
|
<td className="p-3 text-right">
|
|
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
|
|
{__('Edit')}
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile: Cards */}
|
|
{!q.isLoading && !q.isError && (
|
|
<div className="md:hidden space-y-2">
|
|
{filteredProducts.length === 0 ? (
|
|
<div className="p-8 text-center text-muted-foreground">
|
|
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
{searchQuery ? __('No products found matching your search') : __('No products found')}
|
|
</div>
|
|
) : (
|
|
filteredProducts.map((product: any) => (
|
|
<ProductCard
|
|
key={product.id}
|
|
product={product}
|
|
selected={selectedIds.includes(product.id)}
|
|
onSelect={toggleRow}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{data && data.total > perPage && (
|
|
<div className="flex justify-between items-center pt-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
{__('Showing')} {((page - 1) * perPage) + 1} - {Math.min(page * perPage, data.total)} {__('of')} {data.total}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
>
|
|
{__('Previous')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={!data || page * perPage >= data.total}
|
|
>
|
|
{__('Next')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Dialog */}
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{__('Delete products?')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{__('Are you sure you want to delete')} {selectedIds.length} {selectedIds.length === 1 ? __('product') : __('products')}? {__('This action cannot be undone.')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
{deleteMutation.isPending ? __('Deleting...') : __('Delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Mobile Filter Sheet */}
|
|
<FilterBottomSheet
|
|
open={filterSheetOpen}
|
|
onClose={() => setFilterSheetOpen(false)}
|
|
filters={{ status, type, stock_status: stockStatus, category, orderby, order }}
|
|
onFiltersChange={handleFiltersChange}
|
|
onReset={handleResetFilters}
|
|
activeFiltersCount={activeFiltersCount}
|
|
categories={categoriesQ.data || []}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|