Files
WooNooW/admin-spa/src/routes/Products/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

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