// Product search item type for API results type ProductSearchItem = { id: number; name: string; price?: number | string | null; regular_price?: number | string | null; sale_price?: number | string | null; sku?: string; stock?: number | null; virtual?: boolean; downloadable?: boolean; }; import * as React from 'react'; import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency'; import { useQuery } from '@tanstack/react-query'; import { api, ProductsApi, CustomersApi } from '@/lib/api'; import { cn } from '@/lib/utils'; import { __ } from '@/lib/i18n'; import { toast } from 'sonner'; import { Input } from '@/components/ui/input'; 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'; // --- Types ------------------------------------------------------------ export type CountryOption = { code: string; name: string }; export type StatesMap = Record>; // { US: { CA: 'California' } } export type PaymentChannel = { id: string; title: string; meta?: any }; export type PaymentMethod = { id: string; title: string; enabled?: boolean; channels?: PaymentChannel[]; // If present, show channels instead of gateway }; 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; qty: number; name?: string; price?: number; virtual?: boolean; downloadable?: boolean; regular_price?: number; sale_price?: number | null; }; export type ExistingOrderDTO = { id: number; status?: string; billing?: any; shipping?: any; items?: LineItem[]; payment_method?: string; payment_method_id?: string; shipping_method?: string; shipping_method_id?: string; customer_note?: string; currency?: string; currency_symbol?: string; }; export type OrderPayload = { status: string; billing: any; shipping?: any; items?: LineItem[]; payment_method?: string; shipping_method?: string; customer_note?: string; register_as_member?: boolean; coupons?: string[]; }; type Props = { mode: 'create' | 'edit'; initial?: ExistingOrderDTO | null; countries: CountryOption[]; states: StatesMap; defaultCountry?: string; payments?: PaymentMethod[]; shippings?: ShippingMethod[]; onSubmit: (payload: OrderPayload) => Promise | void; className?: string; currency?: string; currencySymbol?: string; leftTop?: React.ReactNode; rightTop?: React.ReactNode; itemsEditable?: boolean; showCoupons?: boolean; formRef?: React.RefObject; hideSubmitButton?: boolean; }; const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed']; // --- Component -------------------------------------------------------- export default function OrderForm({ mode, initial, countries, states, defaultCountry, payments = [], shippings = [], onSubmit, className, leftTop: _leftTop, rightTop, itemsEditable = true, showCoupons = true, currency, currencySymbol, formRef, hideSubmitButton = false, }: Props) { const oneCountryOnly = countries.length === 1; const firstCountry = countries[0]?.code || 'US'; const baseCountry = (defaultCountry && countries.find(c => c.code === defaultCountry)?.code) || firstCountry; // Billing const [bFirst, setBFirst] = React.useState(initial?.billing?.first_name || ''); const [bLast, setBLast] = React.useState(initial?.billing?.last_name || ''); const [bEmail, setBEmail] = React.useState(initial?.billing?.email || ''); const [bPhone, setBPhone] = React.useState(initial?.billing?.phone || ''); const [bAddr1, setBAddr1] = React.useState(initial?.billing?.address_1 || ''); const [bCity, setBCity] = React.useState(initial?.billing?.city || ''); const [bPost, setBPost] = React.useState(initial?.billing?.postcode || ''); const [bCountry, setBCountry] = React.useState(initial?.billing?.country || baseCountry); const [bState, setBState] = React.useState(initial?.billing?.state || ''); // Shipping toggle + dynamic fields const [shipDiff, setShipDiff] = React.useState(Boolean(initial?.shipping && !isEmptyAddress(initial?.shipping))); const [shippingData, setShippingData] = React.useState>(initial?.shipping || {}); // If store sells to a single country, force-select it for billing & shipping React.useEffect(() => { if (oneCountryOnly) { const only = countries[0]?.code || ''; if (only && bCountry !== only) setBCountry(only); } }, [oneCountryOnly, countries, bCountry]); React.useEffect(() => { if (oneCountryOnly) { const only = countries[0]?.code || ''; if (shipDiff) { if (only && shippingData.country !== only) { setShippingData({...shippingData, country: only}); } } else { // keep shipping synced to billing when not different setShippingData({...shippingData, country: bCountry}); } } }, [oneCountryOnly, countries, shipDiff, bCountry, shippingData.country]); // Order meta const [status, setStatus] = React.useState(initial?.status || 'pending'); const [paymentMethod, setPaymentMethod] = React.useState(initial?.payment_method_id || initial?.payment_method || ''); const [shippingMethod, setShippingMethod] = React.useState(initial?.shipping_method_id || initial?.shipping_method || ''); const [note, setNote] = React.useState(initial?.customer_note || ''); const [registerAsMember, setRegisterAsMember] = React.useState(false); const [selectedCustomerId, setSelectedCustomerId] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); const [items, setItems] = React.useState(initial?.items || []); const [couponInput, setCouponInput] = React.useState(''); const [validatedCoupons, setValidatedCoupons] = React.useState([]); const [couponValidating, setCouponValidating] = React.useState(false); // Fetch dynamic checkout fields based on cart items const { data: checkoutFields } = useQuery({ queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))], queryFn: async () => { if (items.length === 0) return null; return api.post('/checkout/fields', { items: items.map(i => ({ product_id: i.product_id, qty: i.qty })), }); }, enabled: items.length > 0, }); // --- Product search for Add Item --- const [searchQ, setSearchQ] = React.useState(''); const [customerSearchQ, setCustomerSearchQ] = React.useState(''); const productsQ = useQuery({ queryKey: ['products', searchQ], queryFn: () => ProductsApi.search(searchQ), enabled: !!searchQ, }); const customersQ = useQuery({ queryKey: ['customers', customerSearchQ], queryFn: () => CustomersApi.search(customerSearchQ), enabled: !!customerSearchQ && customerSearchQ.length >= 2, }); const raw = productsQ.data as any; const products: ProductSearchItem[] = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.rows) ? raw.rows : []; const customersRaw = customersQ.data as any; const customers: any[] = Array.isArray(customersRaw) ? customersRaw : []; const itemsCount = React.useMemo( () => items.reduce((n, it) => n + (Number(it.qty) || 0), 0), [items] ); const itemsTotal = React.useMemo( () => items.reduce((sum, it) => sum + (Number(it.qty) || 0) * (Number(it.price) || 0), 0), [items] ); // Calculate shipping cost const shippingCost = React.useMemo(() => { if (!shippingMethod) return 0; const method = shippings.find(s => s.id === shippingMethod); return method ? Number(method.cost) || 0 : 0; }, [shippingMethod, shippings]); // Calculate discount from validated coupons const couponDiscount = React.useMemo(() => { return validatedCoupons.reduce((sum, c) => sum + (c.discount_amount || 0), 0); }, [validatedCoupons]); // Calculate order total (items + shipping - coupons) const orderTotal = React.useMemo(() => { return Math.max(0, itemsTotal + shippingCost - couponDiscount); }, [itemsTotal, shippingCost, couponDiscount]); // Validate coupon const validateCoupon = async (code: string) => { if (!code.trim()) return; // Check if already added if (validatedCoupons.some(c => c.code.toLowerCase() === code.toLowerCase())) { toast.error(__('Coupon already added')); return; } setCouponValidating(true); try { const response = await api.post('/coupons/validate', { code: code.trim(), subtotal: itemsTotal, }); if (response.valid) { setValidatedCoupons([...validatedCoupons, response]); setCouponInput(''); toast.success(`${__('Coupon applied')}: ${response.code}`); } else { toast.error(response.error || __('Invalid coupon')); } } catch (error: any) { toast.error(error?.message || __('Failed to validate coupon')); } finally { setCouponValidating(false); } }; const removeCoupon = (code: string) => { setValidatedCoupons(validatedCoupons.filter(c => c.code !== code)); }; // Check if cart has physical products const hasPhysicalProduct = React.useMemo( () => items.some(item => { // Check item's stored metadata first if (typeof item.virtual !== 'undefined' || typeof item.downloadable !== 'undefined') { return !item.virtual && !item.downloadable; } // Fallback: check products array (for search results) const product = products.find(p => p.id === item.product_id); return product ? !product.virtual && !product.downloadable : true; // Default to physical if unknown }), [items, products] ); // --- Currency-aware formatting for unit prices and totals --- const storeCur = getStoreCurrency(); const currencyCode = currency || initial?.currency || storeCur.currency; const symbol = initial?.currency_symbol ?? currencySymbol ?? storeCur.symbol; const money = React.useMemo(() => makeMoneyFormatter({ currency: currencyCode, symbol }), [currencyCode, symbol]); // Keep shipping country synced to billing when unchecked React.useEffect(() => { if (!shipDiff) setShippingData({...shippingData, country: bCountry}); }, [shipDiff, bCountry]); // Clamp states when country changes React.useEffect(() => { if (bState && !states[bCountry]?.[bState]) setBState(''); }, [bCountry]); React.useEffect(() => { if (shippingData.state && !states[shippingData.country]?.[shippingData.state]) { setShippingData({...shippingData, state: ''}); } }, [shippingData.country]); const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` })); const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name })); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); // For virtual-only products, don't send address fields const billingData: any = { first_name: bFirst, last_name: bLast, email: bEmail, phone: bPhone, }; // Only add address fields for physical products if (hasPhysicalProduct) { billingData.address_1 = bAddr1; billingData.city = bCity; billingData.state = bState; billingData.postcode = bPost; billingData.country = bCountry; } const payload: OrderPayload = { status, billing: billingData, shipping: shipDiff && hasPhysicalProduct ? shippingData : undefined, payment_method: paymentMethod || undefined, shipping_method: shippingMethod || undefined, customer_note: note || undefined, register_as_member: registerAsMember, items: itemsEditable ? items : undefined, coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined, }; try { setSubmitting(true); await onSubmit(payload); } finally { setSubmitting(false); } } return (
{/* Left: Order details */}
{/* Items and Coupons */} {(mode === 'create' || showCoupons || itemsEditable) && (
{/* Items */}
{__('Items')} {itemsEditable ? (
({ value: String(p.id), label: (
{p.name}
{(typeof p.price !== 'undefined' && p.price !== null && !Number.isNaN(Number(p.price))) && (
{p.sale_price ? ( <> {money(Number(p.sale_price))} {money(Number(p.regular_price))} ) : money(Number(p.price))}
)}
), searchText: p.name, product: p, })) } value={undefined} 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; setItems(prev => [ ...prev, { product_id: p.id, name: p.name, price: Number(p.price) || 0, qty: 1, virtual: p.virtual, downloadable: p.downloadable, } ]); setSearchQ(''); }} placeholder={__('Search products…')} search={searchQ} onSearch={setSearchQ} disabled={!itemsEditable} showCheckIndicator={false} />
) : ( ({__('locked')}) )}
{/* Desktop/table view */}
{items.map((it, idx) => ( ))} {items.length === 0 && ( )}
{__('Product')} {__('Qty')}
{it.name || `Product #${it.product_id}`}
{typeof it.price === 'number' && (
{/* Show strike-through regular price if on sale */} {(() => { // Check item's own data first (for edit mode) if (it.sale_price && it.regular_price && it.sale_price < it.regular_price) { return ( <> {money(Number(it.regular_price))} {money(Number(it.sale_price))} ); } // Fallback: check products array (for create mode) const product = products.find(p => p.id === it.product_id); if (product && product.sale_price && product.regular_price && product.sale_price < product.regular_price) { return ( <> {money(Number(product.sale_price))} {money(Number(product.regular_price))} ); } return money(Number(it.price)); })()}
)}
{ if (!itemsEditable) return; const raw = e.target.value.replace(/[^0-9]/g, ''); const v = Math.max(1, parseInt(raw || '1', 10)); setItems(prev => prev.map((x, i) => i === idx ? { ...x, qty: v } : x)); }} disabled={!itemsEditable} /> {itemsEditable && ( )}
{__('No items yet')}
{/* Mobile/card view */}
{items.length ? ( items.map((it, idx) => (
{it.name || `Product #${it.product_id}`}
{typeof it.price === 'number' && (
{money(Number(it.price))}
)}
{itemsEditable && ( )}
{__('Quantity')}
{ if (!itemsEditable) return; const raw = e.target.value.replace(/[^0-9]/g, ''); const v = Math.max(1, parseInt(raw || '1', 10)); setItems(prev => prev.map((x, i) => i === idx ? { ...x, qty: v } : x)); }} disabled={!itemsEditable} />
)) ) : (
{__('No items yet')}
)}
{__('Items')} {itemsCount}
{__('Subtotal')} {itemsTotal ? money(itemsTotal) : '—'}
{shippingCost > 0 && (
{__('Shipping')} {money(shippingCost)}
)} {couponDiscount > 0 && (
{__('Discount')} -{money(couponDiscount)}
)}
{__('Total')} {money(orderTotal)}
{/* Coupons */} {showCoupons && (
{__('Coupons')} {!itemsEditable && ( ({__('locked')}) )}
{/* Coupon Input */}
setCouponInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); validateCoupon(couponInput); } }} placeholder={__('Enter coupon code')} disabled={!itemsEditable || couponValidating} className="flex-1" />
{/* Applied Coupons */} {validatedCoupons.length > 0 && (
{validatedCoupons.map((coupon) => (
{coupon.code}
{coupon.description && (
{coupon.description}
)}
{coupon.discount_type === 'percent' && `${coupon.amount}% off`} {coupon.discount_type === 'fixed_cart' && `${money(coupon.amount)} off`} {coupon.discount_type === 'fixed_product' && `${money(coupon.amount)} off per item`} {' · '} {__('Discount')}: {money(coupon.discount_amount)}
))}
)}
{__('Enter coupon code and click Apply to validate and calculate discount')}
)}
)} {/* Billing address - only show full address for physical products */}

{__('Billing address')}

{mode === 'create' && ( ({ value: String(c.id), label: (
{c.name || c.email}
{c.email}
), searchText: `${c.name} ${c.email}`, customer: c, }))} value={undefined} onChange={async (val: string) => { const customer = customers.find((c: any) => String(c.id) === val); if (!customer) return; // Fetch full customer data try { const data = await CustomersApi.searchByEmail(customer.email); if (data.found && data.billing) { // Always fill name, email, phone setBFirst(data.billing.first_name || data.first_name || ''); setBLast(data.billing.last_name || data.last_name || ''); setBEmail(data.email || ''); setBPhone(data.billing.phone || ''); // Only fill address fields if cart has physical products if (hasPhysicalProduct) { setBAddr1(data.billing.address_1 || ''); setBCity(data.billing.city || ''); setBPost(data.billing.postcode || ''); setBCountry(data.billing.country || bCountry); setBState(data.billing.state || ''); // Autofill shipping if available if (data.shipping && data.shipping.address_1) { setShipDiff(true); setShippingData({ first_name: data.shipping.first_name || '', last_name: data.shipping.last_name || '', address_1: data.shipping.address_1 || '', city: data.shipping.city || '', postcode: data.shipping.postcode || '', country: data.shipping.country || bCountry, state: data.shipping.state || '', }); } } // Mark customer as selected (hide register checkbox) setSelectedCustomerId(data.user_id); setRegisterAsMember(false); } } catch (e) { console.error('Customer autofill error:', e); } setCustomerSearchQ(''); }} onSearch={setCustomerSearchQ} placeholder={__('Search customer...')} className="w-64" /> )}
setBFirst(e.target.value)} />
setBLast(e.target.value)} />
setBEmail(e.target.value)} />
setBPhone(e.target.value)} />
{/* Only show full address fields for physical products */} {hasPhysicalProduct && ( <>
setBAddr1(e.target.value)} />
setBCity(e.target.value)} />
setBPost(e.target.value)} />
)}
{/* Conditional: Only show address fields and shipping for physical products */} {!hasPhysicalProduct && (
{__('Digital products only - shipping not required')}
)} {/* Shipping toggle */} {hasPhysicalProduct && (
setShipDiff(Boolean(v))} />
)} {/* Shipping address - Dynamic Fields */} {hasPhysicalProduct && shipDiff && checkoutFields?.fields && (

{__('Shipping address')}

{checkoutFields.fields .filter((f: any) => f.fieldset === 'shipping' && !f.hidden) .sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0)) .map((field: any) => { const isWide = ['address_1', 'address_2'].includes(field.key.replace('shipping_', '')); const fieldKey = field.key.replace('shipping_', ''); return (
{field.type === 'select' && field.options ? ( ) : field.key === 'shipping_country' ? ( setShippingData({...shippingData, country: v})} placeholder={field.placeholder || __('Select country')} disabled={oneCountryOnly} /> ) : field.type === 'textarea' ? (