fix: OrderForm variable product issues - empty colors, desktop dialog, duplicate handling

**Issues Fixed:**

1. **Empty Color Values**
   - Problem: Variation attributes showed 'Color:' with no value
   - Cause: Backend returned empty strings for some attributes
   - Fix: Filter empty values with .filter(([_, value]) => value)
   - Result: Only non-empty attributes displayed

2. **Desktop Should Use Dialog**
   - Problem: Both desktop and mobile used Drawer (bottom sheet)
   - Expected: Desktop = Dialog (modal), Mobile = Drawer
   - Fix: Added useMediaQuery hook, conditional rendering
   - Pattern: Same as Settings pages (Payments, Shipping, etc.)

3. **Duplicate Product+Variation Handling**
   - Problem: Same product+variation created new row each time
   - Expected: Should increment quantity of existing row
   - Fix: Check for existing item before adding
   - Logic: findIndex by product_id + variation_id, then increment qty

**Changes to OrderForm.tsx:**
- Added Dialog and useMediaQuery imports
- Added isDesktop detection
- Split variation selector into Desktop (Dialog) and Mobile (Drawer)
- Fixed variationLabel to filter empty values
- Added duplicate check logic before adding to cart
- If exists: increment qty, else: add new item

**Changes to PROJECT_SOP.md:**
- Added Responsive Modal Pattern section
- Documented Dialog/Drawer pattern with code example
- Added rule 3: Same product+variation = increment qty
- Added rule 6: Filter empty attribute values
- Added rule 7: Responsive modals (Dialog/Drawer)

**Result:**
 Color values display correctly (empty values filtered)
 Desktop uses Dialog (centered modal)
 Mobile uses Drawer (bottom sheet)
 Duplicate product+variation increments quantity
 UX matches Tokopedia/Shopee pattern
 Follows Settings page modal pattern
This commit is contained in:
dwindown
2025-11-20 10:44:48 +07:00
parent dfbd992a22
commit be69b40237
4 changed files with 176 additions and 24 deletions

View File

@@ -412,6 +412,40 @@ All CRUD list pages MUST follow these consistent UI patterns:
When adding products to orders, variable products MUST follow the Tokopedia/Shopee pattern:
**Responsive Modal Pattern:**
- **Desktop:** Use `Dialog` component (centered modal)
- **Mobile:** Use `Drawer` component (bottom sheet)
- **Detection:** Use `useMediaQuery("(min-width: 768px)")`
**Implementation:**
```tsx
const isDesktop = useMediaQuery("(min-width: 768px)");
{/* Desktop: Dialog */}
{selectedProduct && isDesktop && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
</DialogHeader>
{/* Variation list */}
</DialogContent>
</Dialog>
)}
{/* Mobile: Drawer */}
{selectedProduct && !isDesktop && (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{product.name}</DrawerTitle>
</DrawerHeader>
{/* Variation list */}
</DrawerContent>
</Drawer>
)}
```
**Desktop Pattern:**
```
[Search Product...]
@@ -438,7 +472,6 @@ When adding products to orders, variable products MUST follow the Tokopedia/Shop
✓ Anker Earbuds
Black Rp296,000 [-] 1 [+] [🗑️]
```
**Rules:**
1. ✅ Each variation is a **separate line item**
@@ -447,6 +480,8 @@ When adding products to orders, variable products MUST follow the Tokopedia/Shop
4. ✅ Mobile: Click variation to open drawer for selection
5. ❌ Don't auto-select first variation
6. ❌ Don't hide variation selector
7. ✅ **Duplicate Handling**: Same product + same variation = increment quantity (NOT new row)
8. ✅ **Empty Attribute Values**: Filter empty attribute values - Use `.filter()` to remove empty strings
**Implementation:**
- Product search shows variable products

View File

@@ -267,7 +267,7 @@ export default function Orders() {
</div>
<div className="flex gap-2 items-center">
<Filter className="w-4 h-4 opacity-60" />
<Filter className="min-w-4 w-4 h-4 opacity-60" />
<Select
value={status ?? 'all'}
onValueChange={(v) => {
@@ -308,7 +308,7 @@ export default function Orders() {
{activeFiltersCount > 0 && (
<button
className="text-sm text-muted-foreground hover:text-foreground underline"
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
onClick={handleResetFilters}
>
{__('Clear filters')}

View File

@@ -30,8 +30,10 @@ import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Badge } from '@/components/ui/badge';
import { useMediaQuery } from '@/hooks/use-media-query';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -278,6 +280,7 @@ export default function OrderForm({
const [selectedProduct, setSelectedProduct] = React.useState<ProductSearchItem | null>(null);
const [selectedVariationId, setSelectedVariationId] = React.useState<number | null>(null);
const [showVariationDrawer, setShowVariationDrawer] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const productsQ = useQuery({
queryKey: ['products', searchQ],
queryFn: () => ProductsApi.search(searchQ),
@@ -711,8 +714,106 @@ export default function OrderForm({
</div>
</div>
{/* Variation Selector Drawer (Mobile + Desktop) */}
{selectedProduct && selectedProduct.type === 'variable' && (
{/* Variation Selector - Dialog (Desktop) */}
{selectedProduct && selectedProduct.type === 'variable' && isDesktop && (
<Dialog open={showVariationDrawer} onOpenChange={setShowVariationDrawer}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selectedProduct.name}</DialogTitle>
<p className="text-sm text-muted-foreground">{__('Select a variation')}</p>
</DialogHeader>
<div className="space-y-3 p-4">
{selectedProduct.variations?.map((variation) => {
const variationLabel = Object.entries(variation.attributes)
.map(([key, value]) => `${key}: ${value || ''}`)
.filter(([_, value]) => value) // Remove empty values
.join(', ');
return (
<button
key={variation.id}
type="button"
onClick={() => {
// Check if this product+variation already exists
const existingIndex = items.findIndex(
item => item.product_id === selectedProduct.id && item.variation_id === variation.id
);
if (existingIndex !== -1) {
// Increment quantity of existing item
setItems(prev => prev.map((item, idx) =>
idx === existingIndex
? { ...item, qty: item.qty + 1 }
: item
));
} else {
// Add new cart item
setItems(prev => [
...prev,
{
product_id: selectedProduct.id,
variation_id: variation.id,
name: selectedProduct.name,
variation_name: variationLabel,
price: variation.price,
regular_price: variation.regular_price,
sale_price: variation.sale_price,
qty: 1,
virtual: selectedProduct.virtual,
downloadable: selectedProduct.downloadable,
}
]);
}
setShowVariationDrawer(false);
setSelectedProduct(null);
setSearchQ('');
}}
className="w-full text-left p-3 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-medium">{variationLabel}</div>
{variation.sku && (
<div className="text-xs text-muted-foreground mt-0.5">
SKU: {variation.sku}
</div>
)}
{variation.stock !== null && (
<div className="text-xs text-muted-foreground mt-0.5">
{__('Stock')}: {variation.stock}
</div>
)}
</div>
<div className="text-right flex-shrink-0">
<div className="font-semibold">
{variation.sale_price ? (
<>
{money(variation.sale_price)}
<div className="text-xs line-through text-muted-foreground">
{money(variation.regular_price)}
</div>
</>
) : (
money(variation.price)
)}
</div>
{!variation.in_stock && (
<Badge variant="destructive" className="mt-1 text-xs">
{__('Out of stock')}
</Badge>
)}
</div>
</div>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
)}
{/* Variation Selector - Drawer (Mobile) */}
{selectedProduct && selectedProduct.type === 'variable' && !isDesktop && (
<Drawer open={showVariationDrawer} onOpenChange={setShowVariationDrawer}>
<DrawerContent>
<DrawerHeader>
@@ -722,7 +823,8 @@ export default function OrderForm({
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{selectedProduct.variations?.map((variation) => {
const variationLabel = Object.entries(variation.attributes)
.map(([key, value]) => `${key}: ${value}`)
.map(([key, value]) => `${key}: ${value || ''}`)
.filter(([_, value]) => value) // Remove empty values
.join(', ');
return (
@@ -730,22 +832,36 @@ export default function OrderForm({
key={variation.id}
type="button"
onClick={() => {
// Add variation as separate cart item
setItems(prev => [
...prev,
{
product_id: selectedProduct.id,
variation_id: variation.id,
name: selectedProduct.name,
variation_name: variationLabel,
price: variation.price,
regular_price: variation.regular_price,
sale_price: variation.sale_price,
qty: 1,
virtual: selectedProduct.virtual,
downloadable: selectedProduct.downloadable,
}
]);
// Check if this product+variation already exists
const existingIndex = items.findIndex(
item => item.product_id === selectedProduct.id && item.variation_id === variation.id
);
if (existingIndex !== -1) {
// Increment quantity of existing item
setItems(prev => prev.map((item, idx) =>
idx === existingIndex
? { ...item, qty: item.qty + 1 }
: item
));
} else {
// Add new cart item
setItems(prev => [
...prev,
{
product_id: selectedProduct.id,
variation_id: variation.id,
name: selectedProduct.name,
variation_name: variationLabel,
price: variation.price,
regular_price: variation.regular_price,
sale_price: variation.sale_price,
qty: 1,
virtual: selectedProduct.virtual,
downloadable: selectedProduct.downloadable,
}
]);
}
setShowVariationDrawer(false);
setSelectedProduct(null);
setSearchQ('');

View File

@@ -247,7 +247,8 @@ export default function Products() {
</button>
</div>
<div className="flex gap-3 flex-wrap">
<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]">