Removed static method-level fallback. Shipping method selector now: 1. Shows 'Enter shipping address to see available rates' when address incomplete 2. Calls calculate_shipping endpoint to get actual WC_Shipping_Rate objects 3. Displays rate-level options (e.g., JNE REG, JNE YES) not method-level This ensures third-party shipping plugins like Rajaongkir, UPS, FedEx display their courier rates correctly.
1315 lines
58 KiB
TypeScript
1315 lines
58 KiB
TypeScript
// Product search item type for API results
|
|
type ProductSearchItem = {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
price?: number | string | null;
|
|
regular_price?: number | string | null;
|
|
sale_price?: number | string | null;
|
|
sku?: string;
|
|
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';
|
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
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';
|
|
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<string, Record<string, string>>; // { 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;
|
|
variation_id?: number; // for variable products
|
|
qty: number;
|
|
name?: string;
|
|
variation_name?: string; // e.g., "Color: Red"
|
|
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> | void;
|
|
className?: string;
|
|
currency?: string;
|
|
currencySymbol?: string;
|
|
leftTop?: React.ReactNode;
|
|
rightTop?: React.ReactNode;
|
|
itemsEditable?: boolean;
|
|
showCoupons?: boolean;
|
|
formRef?: React.RefObject<HTMLFormElement>;
|
|
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<Record<string, any>>(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 [selectedCustomerId, setSelectedCustomerId] = React.useState<number | null>(null);
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
|
|
const [items, setItems] = React.useState<LineItem[]>(initial?.items || []);
|
|
const [couponInput, setCouponInput] = React.useState('');
|
|
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
|
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,
|
|
});
|
|
|
|
// Get effective shipping address (use billing if not shipping to different address)
|
|
const effectiveShippingAddress = React.useMemo(() => {
|
|
if (shipDiff) {
|
|
return shippingData;
|
|
}
|
|
// Use billing address
|
|
return {
|
|
country: bCountry,
|
|
state: bState,
|
|
city: bCity,
|
|
postcode: bPost,
|
|
address_1: bAddr1,
|
|
address_2: '',
|
|
};
|
|
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]);
|
|
|
|
// Check if shipping address is complete enough to calculate rates
|
|
const isShippingAddressComplete = React.useMemo(() => {
|
|
const addr = effectiveShippingAddress;
|
|
// Need at minimum: country, state (if applicable), city
|
|
if (!addr.country) return false;
|
|
if (!addr.city) return false;
|
|
// If country has states, require state
|
|
const countryStates = states[addr.country];
|
|
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}, [effectiveShippingAddress, states]);
|
|
|
|
// Debounce city input to avoid hitting backend on every keypress
|
|
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
|
|
|
React.useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedCity(effectiveShippingAddress.city);
|
|
}, 500); // Wait 500ms after user stops typing
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [effectiveShippingAddress.city]);
|
|
|
|
// Calculate shipping rates dynamically
|
|
const { data: shippingRates, isLoading: shippingLoading } = useQuery({
|
|
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode],
|
|
queryFn: async () => {
|
|
return api.post('/shipping/calculate', {
|
|
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
|
shipping: effectiveShippingAddress,
|
|
});
|
|
},
|
|
enabled: isShippingAddressComplete && items.length > 0,
|
|
gcTime: 0, // Don't cache - always fresh data
|
|
staleTime: 0, // Always refetch when query key changes
|
|
});
|
|
|
|
// Calculate order preview with taxes
|
|
const { data: orderPreview, isLoading: previewLoading } = useQuery({
|
|
queryKey: ['order-preview', items.map(i => ({ product_id: i.product_id, qty: i.qty })), bCountry, bState, bPost, bCity, effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode, shippingMethod, validatedCoupons.map(c => c.code)],
|
|
queryFn: async () => {
|
|
return api.post('/orders/preview', {
|
|
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
|
billing: { country: bCountry, state: bState, postcode: bPost, city: bCity },
|
|
shipping: shipDiff ? shippingData : undefined,
|
|
shipping_method: shippingMethod,
|
|
coupons: validatedCoupons.map(c => c.code),
|
|
});
|
|
},
|
|
enabled: items.length > 0 && !!bCountry && !!shippingMethod,
|
|
gcTime: 0, // Don't cache - always fresh data
|
|
staleTime: 0, // Always refetch when query key changes
|
|
});
|
|
|
|
// --- 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 isDesktop = useMediaQuery("(min-width: 768px)");
|
|
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
|
|
// In edit mode: use existing order shipping total (fixed unless address changes)
|
|
// In create mode: calculate from selected shipping method
|
|
const shippingCost = React.useMemo(() => {
|
|
if (mode === 'edit' && initial?.totals?.shipping !== undefined) {
|
|
// Use existing shipping total from order
|
|
return Number(initial.totals.shipping) || 0;
|
|
}
|
|
// Create mode: calculate from shipping method
|
|
if (!shippingMethod) return 0;
|
|
const method = shippings.find(s => s.id === shippingMethod);
|
|
return method ? Number(method.cost) || 0 : 0;
|
|
}, [mode, initial?.totals?.shipping, 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));
|
|
};
|
|
|
|
// Auto-select first shipping rate when rates change
|
|
React.useEffect(() => {
|
|
if (shippingRates?.methods && shippingRates.methods.length > 0) {
|
|
const firstRateId = shippingRates.methods[0].id;
|
|
// Only auto-select if no method selected or current method not in new rates
|
|
const currentMethodExists = shippingRates.methods.some((m: any) => m.id === shippingMethod);
|
|
if (!shippingMethod || !currentMethodExists) {
|
|
setShippingMethod(firstRateId);
|
|
}
|
|
} else if (shippingRates?.methods && shippingRates.methods.length === 0) {
|
|
// Clear selection if no rates available
|
|
setShippingMethod('');
|
|
}
|
|
}, [shippingRates?.methods]);
|
|
|
|
// 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,
|
|
items: itemsEditable ? items : undefined,
|
|
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
|
};
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
await onSubmit(payload);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form ref={formRef} onSubmit={handleSubmit} className={cn('grid grid-cols-1 lg:grid-cols-3 gap-6', className)}>
|
|
{/* Left: Order details */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Items and Coupons */}
|
|
{(mode === 'create' || showCoupons || itemsEditable) && (
|
|
<div className="space-y-4">
|
|
{/* Items */}
|
|
<div className="rounded border p-4 space-y-3">
|
|
<div className="font-medium flex items-center justify-between">
|
|
<span>{__('Items')}</span>
|
|
{itemsEditable ? (
|
|
<div className="flex items-center gap-2">
|
|
<SearchableSelect
|
|
options={
|
|
products.map((p: ProductSearchItem) => ({
|
|
value: String(p.id),
|
|
label: (
|
|
<div className="leading-tight">
|
|
<div className="font-medium">{p.name}</div>
|
|
{(typeof p.price !== 'undefined' && p.price !== null && !Number.isNaN(Number(p.price))) && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{p.sale_price ? (
|
|
<>
|
|
{money(Number(p.sale_price))} <span className="line-through">{money(Number(p.regular_price))}</span>
|
|
</>
|
|
) : money(Number(p.price))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
searchText: p.name,
|
|
product: p,
|
|
}))
|
|
}
|
|
value={undefined}
|
|
onChange={(val: string) => {
|
|
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
|
|
if (!p) 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,
|
|
{
|
|
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}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs opacity-70">({__('locked')})</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Desktop/table view */}
|
|
<div className="hidden md:block">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left border-b">
|
|
<th className="px-2 py-1">{__('Product')}</th>
|
|
<th className="px-2 py-1 w-24">{__('Qty')}</th>
|
|
<th className="px-2 py-1 w-16"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((it, idx) => (
|
|
<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 */}
|
|
{(() => {
|
|
// Check item's own data first (for edit mode)
|
|
if (it.sale_price && it.regular_price && it.sale_price < it.regular_price) {
|
|
return (
|
|
<>
|
|
<span className="line-through text-gray-400 mr-1">{money(Number(it.regular_price))}</span>
|
|
<span className="text-red-600 font-semibold">{money(Number(it.sale_price))}</span>
|
|
</>
|
|
);
|
|
}
|
|
// 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 (
|
|
<>
|
|
<span className="text-red-600 font-semibold">{money(Number(product.sale_price))}</span>
|
|
<span className="line-through text-gray-400 ml-1">{money(Number(product.regular_price))}</span>
|
|
</>
|
|
);
|
|
}
|
|
return money(Number(it.price));
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-2 py-1">
|
|
<Input
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
min={1}
|
|
className="ui-ctrl w-24 text-center"
|
|
value={String(it.qty)}
|
|
onChange={(e) => {
|
|
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}
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1 text-right">
|
|
{itemsEditable && (
|
|
<button
|
|
className="text-red-600"
|
|
type="button"
|
|
onClick={() => setItems(prev => prev.filter((_, i) => i !== idx))}
|
|
>
|
|
{__('Remove')}
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{items.length === 0 && (
|
|
<tr>
|
|
<td className="px-2 py-4 text-center opacity-70" colSpan={3}>{__('No items yet')}</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile/card view */}
|
|
<div className="md:hidden divide-y">
|
|
{items.length ? (
|
|
items.map((it, idx) => (
|
|
<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>
|
|
)}
|
|
</div>
|
|
<div className="text-right">
|
|
{itemsEditable && (
|
|
<button
|
|
className="text-red-600 text-xs"
|
|
type="button"
|
|
onClick={() => setItems(prev => prev.filter((_, i) => i !== idx))}
|
|
>
|
|
{__('Remove')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 px-1 grid grid-cols-3 gap-2 items-center">
|
|
<div className="col-span-2 text-sm opacity-70">{__('Quantity')}</div>
|
|
<div>
|
|
<Input
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
min={1}
|
|
className="ui-ctrl w-full text-center"
|
|
value={String(it.qty)}
|
|
onChange={(e) => {
|
|
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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="px-2 py-4 text-center opacity-70">{__('No items yet')}</div>
|
|
)}
|
|
</div>
|
|
<div className="rounded-md border px-3 py-2 text-sm bg-white/60 space-y-1.5">
|
|
<div className="flex justify-between">
|
|
<span className="opacity-70">{__('Items')}</span>
|
|
<span>{itemsCount}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="opacity-70">{__('Subtotal')}</span>
|
|
<span>
|
|
{orderPreview ? money(orderPreview.subtotal) : itemsTotal ? money(itemsTotal) : '—'}
|
|
</span>
|
|
</div>
|
|
{(orderPreview?.shipping_total > 0 || shippingCost > 0) && (
|
|
<div className="flex justify-between">
|
|
<span className="opacity-70">{__('Shipping')}</span>
|
|
<span>{orderPreview ? money(orderPreview.shipping_total) : money(shippingCost)}</span>
|
|
</div>
|
|
)}
|
|
{(orderPreview?.discount_total > 0 || couponDiscount > 0) && (
|
|
<div className="flex justify-between text-green-700">
|
|
<span>{__('Discount')}</span>
|
|
<span>-{orderPreview ? money(orderPreview.discount_total) : money(couponDiscount)}</span>
|
|
</div>
|
|
)}
|
|
{orderPreview?.total_tax > 0 && (
|
|
<div className="flex justify-between">
|
|
<span className="opacity-70">{__('Tax')}</span>
|
|
<span>{money(orderPreview.total_tax)}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between pt-1.5 border-t font-medium">
|
|
<span>{__('Total')}</span>
|
|
<span>
|
|
{previewLoading ? (
|
|
<span className="opacity-50">{__('Calculating...')}</span>
|
|
) : orderPreview ? (
|
|
money(orderPreview.total)
|
|
) : (
|
|
money(orderTotal)
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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) => {
|
|
// Build formatted label with styled key:value pairs
|
|
const variationParts = Object.entries(variation.attributes)
|
|
.filter(([_, value]) => value) // Remove empty values
|
|
.map(([key, value]) => ({ key, value: value || '' }));
|
|
|
|
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: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
|
|
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="text-sm">
|
|
{variationParts.map((part, idx) => (
|
|
<span key={part.key}>
|
|
<span className="font-semibold">{part.key}:</span>{' '}
|
|
<span>{part.value}</span>
|
|
{idx < variationParts.length - 1 && ', '}
|
|
</span>
|
|
))}
|
|
</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>
|
|
<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) => {
|
|
// Build formatted label with styled key:value pairs
|
|
const variationParts = Object.entries(variation.attributes)
|
|
.filter(([_, value]) => value) // Remove empty values
|
|
.map(([key, value]) => ({ key, value: value || '' }));
|
|
|
|
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: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
|
|
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="text-sm">
|
|
{variationParts.map((part, idx) => (
|
|
<span key={part.key}>
|
|
<span className="font-semibold">{part.key}:</span>{' '}
|
|
<span>{part.value}</span>
|
|
{idx < variationParts.length - 1 && ', '}
|
|
</span>
|
|
))}
|
|
</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">
|
|
<div className="font-medium flex items-center justify-between">
|
|
<span>{__('Coupons')}</span>
|
|
{!itemsEditable && (
|
|
<span className="text-xs opacity-70">({__('locked')})</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Coupon Input */}
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={couponInput}
|
|
onChange={(e) => setCouponInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
validateCoupon(couponInput);
|
|
}
|
|
}}
|
|
placeholder={__('Enter coupon code')}
|
|
disabled={!itemsEditable || couponValidating}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={() => validateCoupon(couponInput)}
|
|
disabled={!itemsEditable || !couponInput.trim() || couponValidating}
|
|
size="sm"
|
|
>
|
|
{couponValidating ? __('Validating...') : __('Apply')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Applied Coupons */}
|
|
{validatedCoupons.length > 0 && (
|
|
<div className="space-y-2">
|
|
{validatedCoupons.map((coupon) => (
|
|
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded text-sm">
|
|
<div className="flex-1">
|
|
<div className="font-medium text-green-800">{coupon.code}</div>
|
|
{coupon.description && (
|
|
<div className="text-xs text-green-700 opacity-80">{coupon.description}</div>
|
|
)}
|
|
<div className="text-xs text-green-700 mt-1">
|
|
{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`}
|
|
{' · '}
|
|
<span className="font-medium">{__('Discount')}: {money(coupon.discount_amount)}</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeCoupon(coupon.code)}
|
|
disabled={!itemsEditable}
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
>
|
|
{__('Remove')}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-[11px] opacity-70">
|
|
{__('Enter coupon code and click Apply to validate and calculate discount')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Billing address - only show full address for physical products */}
|
|
<div className="rounded border p-4 space-y-3">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
|
{mode === 'create' && (
|
|
<SearchableSelect
|
|
options={customers.map((c: any) => ({
|
|
value: String(c.id),
|
|
label: (
|
|
<div className="leading-tight">
|
|
<div className="font-medium">{c.name || c.email}</div>
|
|
<div className="text-xs text-muted-foreground">{c.email}</div>
|
|
</div>
|
|
),
|
|
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
|
|
setSelectedCustomerId(data.user_id);
|
|
}
|
|
} catch (e) {
|
|
console.error('Customer autofill error:', e);
|
|
}
|
|
|
|
setCustomerSearchQ('');
|
|
}}
|
|
onSearch={setCustomerSearchQ}
|
|
placeholder={__('Search customer...')}
|
|
className="w-64"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>{__('First name')}</Label>
|
|
<Input className="rounded-md border px-3 py-2" value={bFirst} onChange={e => setBFirst(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>{__('Last name')}</Label>
|
|
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e => setBLast(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>{__('Email')}</Label>
|
|
<Input
|
|
inputMode="email"
|
|
autoComplete="email"
|
|
className="rounded-md border px-3 py-2 appearance-none"
|
|
value={bEmail}
|
|
onChange={e => setBEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>{__('Phone')}</Label>
|
|
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e => setBPhone(e.target.value)} />
|
|
</div>
|
|
{/* Only show full address fields for physical products */}
|
|
{hasPhysicalProduct && (
|
|
<>
|
|
<div className="md:col-span-2">
|
|
<Label>{__('Address')}</Label>
|
|
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e => setBAddr1(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>{__('City')}</Label>
|
|
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e => setBCity(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>{__('Postcode')}</Label>
|
|
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e => setBPost(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>{__('Country')}</Label>
|
|
<SearchableSelect
|
|
options={countryOptions}
|
|
value={bCountry}
|
|
onChange={setBCountry}
|
|
placeholder={countries.length ? __('Select country') : __('No countries')}
|
|
disabled={oneCountryOnly}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>{__('State/Province')}</Label>
|
|
<Select value={bState} onValueChange={setBState}>
|
|
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
|
|
<SelectContent className="max-h-64">
|
|
{bStateOptions.length ? bStateOptions.map(o => (
|
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
)) : (
|
|
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Conditional: Only show address fields and shipping for physical products */}
|
|
{!hasPhysicalProduct && (
|
|
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
|
|
{__('Digital products only - shipping not required')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Shipping toggle */}
|
|
{hasPhysicalProduct && (
|
|
<div className="pt-2 mt-4">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Checkbox id="shipDiff" checked={shipDiff} onCheckedChange={(v) => setShipDiff(Boolean(v))} />
|
|
<Label htmlFor="shipDiff" className="leading-none">{__('Ship to a different address')}</Label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Shipping address - Dynamic Fields */}
|
|
{hasPhysicalProduct && shipDiff && checkoutFields?.fields && (
|
|
<div className="rounded border p-4 space-y-3 mt-4">
|
|
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{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 (
|
|
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
|
<Label>
|
|
{field.label}
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
|
</Label>
|
|
{field.type === 'select' && field.options ? (
|
|
<Select
|
|
value={shippingData[fieldKey] || ''}
|
|
onValueChange={(v) => setShippingData({ ...shippingData, [fieldKey]: v })}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder={field.placeholder || field.label} />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-64">
|
|
{Object.entries(field.options).map(([value, label]: [string, any]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : field.key === 'shipping_country' ? (
|
|
<SearchableSelect
|
|
options={countryOptions}
|
|
value={shippingData.country || ''}
|
|
onChange={(v) => setShippingData({ ...shippingData, country: v })}
|
|
placeholder={field.placeholder || __('Select country')}
|
|
disabled={oneCountryOnly}
|
|
/>
|
|
) : field.type === 'textarea' ? (
|
|
<Textarea
|
|
value={shippingData[fieldKey] || ''}
|
|
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
/>
|
|
) : (
|
|
<Input
|
|
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
|
value={shippingData[fieldKey] || ''}
|
|
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Settings + Actions */}
|
|
<aside className="lg:col-span-1">
|
|
<div className="sticky top-4 space-y-4">
|
|
{rightTop}
|
|
<div className="rounded border p-4 space-y-3">
|
|
<div className="font-medium">{__('Order Settings')}</div>
|
|
<div>
|
|
<Label>{__('Status')}</Label>
|
|
<Select value={status} onValueChange={setStatus}>
|
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_LIST.map((s) => (
|
|
<SelectItem key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>{__('Payment method')}</Label>
|
|
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
|
<SelectTrigger className="w-full"><SelectValue placeholder={payments.length ? __('Select payment') : __('No methods')} /></SelectTrigger>
|
|
<SelectContent>
|
|
{payments.map(p => {
|
|
// If gateway has channels, show channels instead of gateway
|
|
if (p.channels && p.channels.length > 0) {
|
|
return p.channels.map((channel: any) => (
|
|
<SelectItem key={channel.id} value={channel.id}>
|
|
{channel.title}
|
|
</SelectItem>
|
|
));
|
|
}
|
|
// Otherwise show gateway
|
|
return (
|
|
<SelectItem key={p.id} value={p.id}>{p.title}</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{/* Only show shipping method for physical products */}
|
|
{hasPhysicalProduct && (
|
|
<div>
|
|
<Label>{__('Shipping method')}</Label>
|
|
{!isShippingAddressComplete ? (
|
|
/* Prompt user to enter address first */
|
|
<div className="text-sm text-muted-foreground py-2 italic">
|
|
{__('Enter shipping address to see available rates')}
|
|
</div>
|
|
) : shippingLoading ? (
|
|
<div className="text-sm text-muted-foreground py-2">{__('Calculating rates...')}</div>
|
|
) : shippingRates?.methods && shippingRates.methods.length > 0 ? (
|
|
<Select value={shippingMethod} onValueChange={setShippingMethod}>
|
|
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select shipping')} /></SelectTrigger>
|
|
<SelectContent>
|
|
{shippingRates.methods.map((rate: any) => (
|
|
<SelectItem key={rate.id} value={rate.id}>
|
|
{rate.label} - {money(rate.cost)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
/* Address is complete but no methods returned */
|
|
<div className="text-sm text-muted-foreground py-2">{__('No shipping methods available for this address')}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded border p-4 space-y-2">
|
|
<Label>{__('Customer note (optional)')}</Label>
|
|
<Textarea value={note} onChange={e => setNote(e.target.value)} placeholder={__('Write a note for this order…')} />
|
|
</div>
|
|
|
|
{!hideSubmitButton && (
|
|
<Button type="submit" disabled={submitting} className="w-full">
|
|
{submitting ? (mode === 'edit' ? __('Saving…') : __('Creating…')) : (mode === 'edit' ? __('Save changes') : __('Create order'))}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function isEmptyAddress(a: any) {
|
|
if (!a) return true;
|
|
const keys = ['first_name', 'last_name', 'address_1', 'city', 'state', 'postcode', 'country'];
|
|
return keys.every(k => !a[k]);
|
|
} |