feat: Implement variable product handling in OrderForm (Tokopedia pattern)

Following PROJECT_SOP.md section 5.7 - Variable Product Handling:

**Backend (OrdersController.php):**
- Updated /products/search endpoint to return:
  - Product type (simple/variable)
  - Variations array with attributes, prices, stock
  - Formatted attribute names (Color, Size, etc.)

**Frontend (OrderForm.tsx):**
- Updated ProductSearchItem type to include variations
- Updated LineItem type to support variation_id and variation_name
- Added variation selector drawer (mobile + desktop)
- Each variation = separate cart item row
- Display variation name below product name
- Fixed remove button to work with variations (by index)

**UX Pattern:**
1. Search product → If variable, show variation drawer
2. Select variation → Add as separate line item
3. Can add same product with different variations
4. Each variation shown clearly: 'Product Name' + 'Color: Red'

**Result:**
 Tokopedia/Shopee pattern implemented
 No auto-selection of first variation
 Each variation is independent cart item
 Works on mobile and desktop

**Next:** Fix PageHeader max-w-5xl to only apply on settings pages
This commit is contained in:
dwindown
2025-11-20 09:47:14 +07:00
parent 746148cc5f
commit 9a6a434c48
2 changed files with 158 additions and 7 deletions

View File

@@ -2,6 +2,7 @@
type ProductSearchItem = {
id: number;
name: string;
type: string;
price?: number | string | null;
regular_price?: number | string | null;
sale_price?: number | string | null;
@@ -9,6 +10,16 @@ type ProductSearchItem = {
stock?: number | null;
virtual?: boolean;
downloadable?: boolean;
variations?: {
id: number;
attributes: Record<string, string>;
price: number;
regular_price: number;
sale_price: number | null;
sku: string;
stock: number | null;
in_stock: boolean;
}[];
};
import * as React from 'react';
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
@@ -18,10 +29,12 @@ import { cn } from '@/lib/utils';
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 { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { SearchableSelect } from '@/components/ui/searchable-select';
@@ -40,8 +53,10 @@ export type ShippingMethod = { id: string; title: string; cost: number };
export type LineItem = {
line_item_id?: number; // present in edit mode to update existing line
product_id: number;
variation_id?: number; // for variable products
qty: number;
name?: string;
variation_name?: string; // e.g., "Color: Red"
price?: number;
virtual?: boolean;
downloadable?: boolean;
@@ -260,6 +275,9 @@ export default function OrderForm({
// --- Product search for Add Item ---
const [searchQ, setSearchQ] = React.useState('');
const [customerSearchQ, setCustomerSearchQ] = React.useState('');
const [selectedProduct, setSelectedProduct] = React.useState<ProductSearchItem | null>(null);
const [selectedVariationId, setSelectedVariationId] = React.useState<number | null>(null);
const [showVariationDrawer, setShowVariationDrawer] = React.useState(false);
const productsQ = useQuery({
queryKey: ['products', searchQ],
queryFn: () => ProductsApi.search(searchQ),
@@ -476,7 +494,16 @@ export default function OrderForm({
onChange={(val: string) => {
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
if (!p) return;
if (items.find(x => x.product_id === p.id)) return;
// If variable product, show variation selector
if (p.type === 'variable' && p.variations && p.variations.length > 0) {
setSelectedProduct(p);
setSelectedVariationId(null);
setShowVariationDrawer(true);
return;
}
// Simple product - add directly (but allow duplicates for different quantities)
setItems(prev => [
...prev,
{
@@ -514,10 +541,13 @@ export default function OrderForm({
</thead>
<tbody>
{items.map((it, idx) => (
<tr key={it.product_id} className="border-b last:border-0">
<tr key={`${it.product_id}-${it.variation_id || 'simple'}-${idx}`} className="border-b last:border-0">
<td className="px-2 py-1">
<div>
<div>{it.name || `Product #${it.product_id}`}</div>
{it.variation_name && (
<div className="text-xs text-muted-foreground">{it.variation_name}</div>
)}
{typeof it.price === 'number' && (
<div className="text-xs opacity-60">
{/* Show strike-through regular price if on sale */}
@@ -568,7 +598,7 @@ export default function OrderForm({
<button
className="text-red-600"
type="button"
onClick={() => setItems(prev => prev.filter((x) => x.product_id !== it.product_id))}
onClick={() => setItems(prev => prev.filter((_, i) => i !== idx))}
>
{__('Remove')}
</button>
@@ -589,10 +619,13 @@ export default function OrderForm({
<div className="md:hidden divide-y">
{items.length ? (
items.map((it, idx) => (
<div key={it.product_id} className="py-3">
<div key={`${it.product_id}-${it.variation_id || 'simple'}-${idx}`} className="py-3">
<div className="px-1 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium truncate">{it.name || `Product #${it.product_id}`}</div>
{it.variation_name && (
<div className="text-xs text-muted-foreground">{it.variation_name}</div>
)}
{typeof it.price === 'number' && (
<div className="text-xs opacity-60">{money(Number(it.price))}</div>
)}
@@ -602,7 +635,7 @@ export default function OrderForm({
<button
className="text-red-600 text-xs"
type="button"
onClick={() => setItems(prev => prev.filter((x) => x.product_id !== it.product_id))}
onClick={() => setItems(prev => prev.filter((_, i) => i !== idx))}
>
{__('Remove')}
</button>
@@ -678,6 +711,89 @@ export default function OrderForm({
</div>
</div>
{/* Variation Selector Drawer (Mobile + Desktop) */}
{selectedProduct && selectedProduct.type === 'variable' && (
<Drawer open={showVariationDrawer} onOpenChange={setShowVariationDrawer}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{selectedProduct.name}</DrawerTitle>
<p className="text-sm text-muted-foreground">{__('Select a variation')}</p>
</DrawerHeader>
<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}`)
.join(', ');
return (
<button
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,
}
]);
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>
</DrawerContent>
</Drawer>
)}
{/* Coupons */}
{showCoupons && (
<div className="rounded border p-4 space-y-3">