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 = {
|
type ProductSearchItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: string;
|
||||||
price?: number | string | null;
|
price?: number | string | null;
|
||||||
regular_price?: number | string | null;
|
regular_price?: number | string | null;
|
||||||
sale_price?: number | string | null;
|
sale_price?: number | string | null;
|
||||||
@@ -9,6 +10,16 @@ type ProductSearchItem = {
|
|||||||
stock?: number | null;
|
stock?: number | null;
|
||||||
virtual?: boolean;
|
virtual?: boolean;
|
||||||
downloadable?: 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 * as React from 'react';
|
||||||
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
|
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
|
||||||
@@ -18,10 +29,12 @@ import { cn } from '@/lib/utils';
|
|||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
|
|
||||||
@@ -40,8 +53,10 @@ export type ShippingMethod = { id: string; title: string; cost: number };
|
|||||||
export type LineItem = {
|
export type LineItem = {
|
||||||
line_item_id?: number; // present in edit mode to update existing line
|
line_item_id?: number; // present in edit mode to update existing line
|
||||||
product_id: number;
|
product_id: number;
|
||||||
|
variation_id?: number; // for variable products
|
||||||
qty: number;
|
qty: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
variation_name?: string; // e.g., "Color: Red"
|
||||||
price?: number;
|
price?: number;
|
||||||
virtual?: boolean;
|
virtual?: boolean;
|
||||||
downloadable?: boolean;
|
downloadable?: boolean;
|
||||||
@@ -260,6 +275,9 @@ export default function OrderForm({
|
|||||||
// --- Product search for Add Item ---
|
// --- Product search for Add Item ---
|
||||||
const [searchQ, setSearchQ] = React.useState('');
|
const [searchQ, setSearchQ] = React.useState('');
|
||||||
const [customerSearchQ, setCustomerSearchQ] = 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({
|
const productsQ = useQuery({
|
||||||
queryKey: ['products', searchQ],
|
queryKey: ['products', searchQ],
|
||||||
queryFn: () => ProductsApi.search(searchQ),
|
queryFn: () => ProductsApi.search(searchQ),
|
||||||
@@ -476,7 +494,16 @@ export default function OrderForm({
|
|||||||
onChange={(val: string) => {
|
onChange={(val: string) => {
|
||||||
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
|
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
|
||||||
if (!p) return;
|
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 => [
|
setItems(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -514,10 +541,13 @@ export default function OrderForm({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((it, idx) => (
|
{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">
|
<td className="px-2 py-1">
|
||||||
<div>
|
<div>
|
||||||
<div>{it.name || `Product #${it.product_id}`}</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' && (
|
{typeof it.price === 'number' && (
|
||||||
<div className="text-xs opacity-60">
|
<div className="text-xs opacity-60">
|
||||||
{/* Show strike-through regular price if on sale */}
|
{/* Show strike-through regular price if on sale */}
|
||||||
@@ -568,7 +598,7 @@ export default function OrderForm({
|
|||||||
<button
|
<button
|
||||||
className="text-red-600"
|
className="text-red-600"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setItems(prev => prev.filter((x) => x.product_id !== it.product_id))}
|
onClick={() => setItems(prev => prev.filter((_, i) => i !== idx))}
|
||||||
>
|
>
|
||||||
{__('Remove')}
|
{__('Remove')}
|
||||||
</button>
|
</button>
|
||||||
@@ -589,10 +619,13 @@ export default function OrderForm({
|
|||||||
<div className="md:hidden divide-y">
|
<div className="md:hidden divide-y">
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((it, idx) => (
|
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="px-1 flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium truncate">{it.name || `Product #${it.product_id}`}</div>
|
<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' && (
|
{typeof it.price === 'number' && (
|
||||||
<div className="text-xs opacity-60">{money(Number(it.price))}</div>
|
<div className="text-xs opacity-60">{money(Number(it.price))}</div>
|
||||||
)}
|
)}
|
||||||
@@ -602,7 +635,7 @@ export default function OrderForm({
|
|||||||
<button
|
<button
|
||||||
className="text-red-600 text-xs"
|
className="text-red-600 text-xs"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setItems(prev => prev.filter((x) => x.product_id !== it.product_id))}
|
onClick={() => setItems(prev => prev.filter((_, i) => i !== idx))}
|
||||||
>
|
>
|
||||||
{__('Remove')}
|
{__('Remove')}
|
||||||
</button>
|
</button>
|
||||||
@@ -678,6 +711,89 @@ export default function OrderForm({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Coupons */}
|
||||||
{showCoupons && (
|
{showCoupons && (
|
||||||
<div className="rounded border p-4 space-y-3">
|
<div className="rounded border p-4 space-y-3">
|
||||||
|
|||||||
@@ -1138,9 +1138,10 @@ class OrdersController {
|
|||||||
|
|
||||||
$prods = wc_get_products( $args );
|
$prods = wc_get_products( $args );
|
||||||
$rows = array_map( function( $p ) {
|
$rows = array_map( function( $p ) {
|
||||||
return [
|
$data = [
|
||||||
'id' => $p->get_id(),
|
'id' => $p->get_id(),
|
||||||
'name' => $p->get_name(),
|
'name' => $p->get_name(),
|
||||||
|
'type' => $p->get_type(),
|
||||||
'price' => (float) $p->get_price(),
|
'price' => (float) $p->get_price(),
|
||||||
'regular_price' => (float) $p->get_regular_price(),
|
'regular_price' => (float) $p->get_regular_price(),
|
||||||
'sale_price' => $p->get_sale_price() ? (float) $p->get_sale_price() : null,
|
'sale_price' => $p->get_sale_price() ? (float) $p->get_sale_price() : null,
|
||||||
@@ -1148,7 +1149,41 @@ class OrdersController {
|
|||||||
'stock' => $p->get_stock_quantity(),
|
'stock' => $p->get_stock_quantity(),
|
||||||
'virtual' => $p->is_virtual(),
|
'virtual' => $p->is_virtual(),
|
||||||
'downloadable' => $p->is_downloadable(),
|
'downloadable' => $p->is_downloadable(),
|
||||||
|
'variations' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// If variable product, include variations
|
||||||
|
if ( $p->get_type() === 'variable' ) {
|
||||||
|
$variation_ids = $p->get_children();
|
||||||
|
foreach ( $variation_ids as $variation_id ) {
|
||||||
|
$variation = wc_get_product( $variation_id );
|
||||||
|
if ( ! $variation ) continue;
|
||||||
|
|
||||||
|
// Get variation attributes
|
||||||
|
$attributes = [];
|
||||||
|
foreach ( $variation->get_variation_attributes() as $attr_name => $attr_value ) {
|
||||||
|
// Remove 'attribute_' prefix and format name
|
||||||
|
$clean_name = str_replace( 'attribute_', '', $attr_name );
|
||||||
|
$clean_name = str_replace( 'pa_', '', $clean_name ); // Remove taxonomy prefix if present
|
||||||
|
$clean_name = ucfirst( str_replace( [ '-', '_' ], ' ', $clean_name ) );
|
||||||
|
|
||||||
|
$attributes[ $clean_name ] = $attr_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['variations'][] = [
|
||||||
|
'id' => $variation->get_id(),
|
||||||
|
'attributes' => $attributes,
|
||||||
|
'price' => (float) $variation->get_price(),
|
||||||
|
'regular_price' => (float) $variation->get_regular_price(),
|
||||||
|
'sale_price' => $variation->get_sale_price() ? (float) $variation->get_sale_price() : null,
|
||||||
|
'sku' => $variation->get_sku(),
|
||||||
|
'stock' => $variation->get_stock_quantity(),
|
||||||
|
'in_stock' => $variation->is_in_stock(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
}, $prods );
|
}, $prods );
|
||||||
|
|
||||||
return new WP_REST_Response( [ 'rows' => $rows ], 200 );
|
return new WP_REST_Response( [ 'rows' => $rows ], 200 );
|
||||||
|
|||||||
Reference in New Issue
Block a user