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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user