|
|
|
|
@@ -39,6 +39,7 @@ 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';
|
|
|
|
|
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
|
|
|
|
|
|
|
|
|
// --- Types ------------------------------------------------------------
|
|
|
|
|
export type CountryOption = { code: string; name: string };
|
|
|
|
|
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
|
|
|
|
|
customer_note?: string;
|
|
|
|
|
currency?: string;
|
|
|
|
|
currency_symbol?: string;
|
|
|
|
|
totals?: {
|
|
|
|
|
total_items?: number;
|
|
|
|
|
total_shipping?: number;
|
|
|
|
|
total_tax?: number;
|
|
|
|
|
total_discount?: number;
|
|
|
|
|
total?: number;
|
|
|
|
|
shipping?: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type OrderPayload = {
|
|
|
|
|
@@ -91,6 +100,7 @@ export type OrderPayload = {
|
|
|
|
|
customer_note?: string;
|
|
|
|
|
register_as_member?: boolean;
|
|
|
|
|
coupons?: string[];
|
|
|
|
|
custom_fields?: Record<string, string>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
@@ -189,6 +199,9 @@ export default function OrderForm({
|
|
|
|
|
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
|
|
|
|
const [couponValidating, setCouponValidating] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
// Custom field values (for plugin fields like destination_id)
|
|
|
|
|
const [customFieldData, setCustomFieldData] = React.useState<Record<string, string>>({});
|
|
|
|
|
|
|
|
|
|
// 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 }))],
|
|
|
|
|
@@ -201,10 +214,46 @@ export default function OrderForm({
|
|
|
|
|
enabled: items.length > 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Apply default values from API for hidden fields (like customer checkout does)
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!checkoutFields?.fields) return;
|
|
|
|
|
|
|
|
|
|
// Initialize custom field defaults
|
|
|
|
|
const customDefaults: Record<string, string> = {};
|
|
|
|
|
checkoutFields.fields.forEach((field: any) => {
|
|
|
|
|
if (field.default && field.custom) {
|
|
|
|
|
customDefaults[field.key] = field.default;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (Object.keys(customDefaults).length > 0) {
|
|
|
|
|
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set billing country default for hidden fields (e.g., Indonesia-only stores)
|
|
|
|
|
const billingCountryField = checkoutFields.fields.find((f: any) => f.key === 'billing_country');
|
|
|
|
|
if ((billingCountryField?.type === 'hidden' || billingCountryField?.hidden) && billingCountryField.default && !bCountry) {
|
|
|
|
|
setBCountry(billingCountryField.default);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set shipping country default for hidden fields
|
|
|
|
|
const shippingCountryField = checkoutFields.fields.find((f: any) => f.key === 'shipping_country');
|
|
|
|
|
if ((shippingCountryField?.type === 'hidden' || shippingCountryField?.hidden) && shippingCountryField.default && !shippingData.country) {
|
|
|
|
|
setShippingData(prev => ({ ...prev, country: shippingCountryField.default }));
|
|
|
|
|
}
|
|
|
|
|
}, [checkoutFields?.fields]);
|
|
|
|
|
|
|
|
|
|
// Get effective shipping address (use billing if not shipping to different address)
|
|
|
|
|
const effectiveShippingAddress = React.useMemo(() => {
|
|
|
|
|
// Get destination_id from custom fields (Rajaongkir)
|
|
|
|
|
const destinationId = shipDiff
|
|
|
|
|
? customFieldData['shipping_destination_id']
|
|
|
|
|
: customFieldData['billing_destination_id'];
|
|
|
|
|
|
|
|
|
|
if (shipDiff) {
|
|
|
|
|
return shippingData;
|
|
|
|
|
return {
|
|
|
|
|
...shippingData,
|
|
|
|
|
destination_id: destinationId || undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// Use billing address
|
|
|
|
|
return {
|
|
|
|
|
@@ -214,22 +263,19 @@ export default function OrderForm({
|
|
|
|
|
postcode: bPost,
|
|
|
|
|
address_1: bAddr1,
|
|
|
|
|
address_2: '',
|
|
|
|
|
destination_id: destinationId || undefined,
|
|
|
|
|
};
|
|
|
|
|
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]);
|
|
|
|
|
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1, customFieldData]);
|
|
|
|
|
|
|
|
|
|
// Check if shipping address is complete enough to calculate rates
|
|
|
|
|
// Should match customer checkout: just needs country to fetch (destination_id can provide more specific 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]);
|
|
|
|
|
// Need at minimum: country OR destination_id
|
|
|
|
|
// destination_id from Rajaongkir is sufficient to calculate shipping
|
|
|
|
|
if (addr.destination_id) return true;
|
|
|
|
|
return !!addr.country;
|
|
|
|
|
}, [effectiveShippingAddress]);
|
|
|
|
|
|
|
|
|
|
// Debounce city input to avoid hitting backend on every keypress
|
|
|
|
|
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
|
|
|
|
@@ -244,7 +290,7 @@ export default function OrderForm({
|
|
|
|
|
|
|
|
|
|
// 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],
|
|
|
|
|
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode, effectiveShippingAddress.destination_id],
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
return api.post('/shipping/calculate', {
|
|
|
|
|
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
|
|
|
|
@@ -424,6 +470,43 @@ export default function OrderForm({
|
|
|
|
|
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 }));
|
|
|
|
|
|
|
|
|
|
// Helper to get billing field config from API - returns null if hidden
|
|
|
|
|
const getBillingField = (key: string) => {
|
|
|
|
|
if (!checkoutFields?.fields) return { label: '', required: false }; // fallback when API not loaded
|
|
|
|
|
const field = checkoutFields.fields.find((f: any) => f.key === key);
|
|
|
|
|
// Check both hidden flag and type === 'hidden'
|
|
|
|
|
if (!field || field.hidden || field.type === 'hidden') return null;
|
|
|
|
|
return field;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Helper to check if billing field should have full width
|
|
|
|
|
const isBillingFieldWide = (key: string) => {
|
|
|
|
|
const field = getBillingField(key);
|
|
|
|
|
if (!field) return false;
|
|
|
|
|
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
|
|
|
|
|
return hasFormRowWide || ['billing_address_1', 'billing_address_2'].includes(key);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Derive custom fields from API (for plugin fields like destination_id)
|
|
|
|
|
const billingCustomFields = React.useMemo(() => {
|
|
|
|
|
if (!checkoutFields?.fields) return [];
|
|
|
|
|
return checkoutFields.fields
|
|
|
|
|
.filter((f: any) => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden')
|
|
|
|
|
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
|
|
|
|
}, [checkoutFields?.fields]);
|
|
|
|
|
|
|
|
|
|
const shippingCustomFields = React.useMemo(() => {
|
|
|
|
|
if (!checkoutFields?.fields) return [];
|
|
|
|
|
return checkoutFields.fields
|
|
|
|
|
.filter((f: any) => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden')
|
|
|
|
|
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
|
|
|
|
}, [checkoutFields?.fields]);
|
|
|
|
|
|
|
|
|
|
// Helper to handle custom field changes
|
|
|
|
|
const handleCustomFieldChange = (key: string, value: string) => {
|
|
|
|
|
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
@@ -453,6 +536,7 @@ export default function OrderForm({
|
|
|
|
|
customer_note: note || undefined,
|
|
|
|
|
items: itemsEditable ? items : undefined,
|
|
|
|
|
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
|
|
|
|
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
@@ -1006,138 +1090,229 @@ export default function OrderForm({
|
|
|
|
|
)}
|
|
|
|
|
</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;
|
|
|
|
|
{/* Billing address - only show when items are added (so checkout fields API is loaded) */}
|
|
|
|
|
{items.length > 0 && (
|
|
|
|
|
<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 || '');
|
|
|
|
|
// 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 || '');
|
|
|
|
|
// 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 || '',
|
|
|
|
|
});
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark customer as selected
|
|
|
|
|
setSelectedCustomerId(data.user_id);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Customer autofill error:', e);
|
|
|
|
|
}
|
|
|
|
|
} 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)} />
|
|
|
|
|
setCustomerSearchQ('');
|
|
|
|
|
}}
|
|
|
|
|
onSearch={setCustomerSearchQ}
|
|
|
|
|
placeholder={__('Search customer...')}
|
|
|
|
|
className="w-64"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
|
|
|
{/* Dynamic billing fields - respects API visibility, labels, required status */}
|
|
|
|
|
{getBillingField('billing_first_name') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_first_name')?.label || __('First name')}
|
|
|
|
|
{getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="rounded-md border px-3 py-2"
|
|
|
|
|
value={bFirst}
|
|
|
|
|
onChange={e => setBFirst(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_first_name')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label>{__('State/Province')}</Label>
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
options={bStateOptions}
|
|
|
|
|
value={bState}
|
|
|
|
|
onChange={setBState}
|
|
|
|
|
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
|
|
|
|
disabled={!bStateOptions.length}
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_last_name') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_last_name')?.label || __('Last name')}
|
|
|
|
|
{getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="rounded-md border px-3 py-2"
|
|
|
|
|
value={bLast}
|
|
|
|
|
onChange={e => setBLast(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_last_name')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_email') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_email') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_email')?.label || __('Email')}
|
|
|
|
|
{getBillingField('billing_email')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
inputMode="email"
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
className="rounded-md border px-3 py-2 appearance-none"
|
|
|
|
|
value={bEmail}
|
|
|
|
|
onChange={e => setBEmail(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_email')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_phone') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_phone') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_phone')?.label || __('Phone')}
|
|
|
|
|
{getBillingField('billing_phone')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="rounded-md border px-3 py-2"
|
|
|
|
|
value={bPhone}
|
|
|
|
|
onChange={e => setBPhone(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_phone')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{/* Address fields - only shown for physical products AND when not hidden by API */}
|
|
|
|
|
{hasPhysicalProduct && (
|
|
|
|
|
<>
|
|
|
|
|
{getBillingField('billing_address_1') && (
|
|
|
|
|
<div className="md:col-span-2">
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_address_1')?.label || __('Address')}
|
|
|
|
|
{getBillingField('billing_address_1')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="rounded-md border px-3 py-2"
|
|
|
|
|
value={bAddr1}
|
|
|
|
|
onChange={e => setBAddr1(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_address_1')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_city') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_city') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_city')?.label || __('City')}
|
|
|
|
|
{getBillingField('billing_city')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="rounded-md border px-3 py-2"
|
|
|
|
|
value={bCity}
|
|
|
|
|
onChange={e => setBCity(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_city')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_postcode') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_postcode') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_postcode')?.label || __('Postcode')}
|
|
|
|
|
{getBillingField('billing_postcode')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="rounded-md border px-3 py-2"
|
|
|
|
|
value={bPost}
|
|
|
|
|
onChange={e => setBPost(e.target.value)}
|
|
|
|
|
required={getBillingField('billing_postcode')?.required}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_country') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_country') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_country')?.label || __('Country')}
|
|
|
|
|
{getBillingField('billing_country')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
options={countryOptions}
|
|
|
|
|
value={bCountry}
|
|
|
|
|
onChange={setBCountry}
|
|
|
|
|
placeholder={countries.length ? __('Select country') : __('No countries')}
|
|
|
|
|
disabled={oneCountryOnly}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{getBillingField('billing_state') && (
|
|
|
|
|
<div className={isBillingFieldWide('billing_state') ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
{getBillingField('billing_state')?.label || __('State/Province')}
|
|
|
|
|
{getBillingField('billing_state')?.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</Label>
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
options={bStateOptions}
|
|
|
|
|
value={bState}
|
|
|
|
|
onChange={setBState}
|
|
|
|
|
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
|
|
|
|
disabled={!bStateOptions.length}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Billing custom fields from plugins (e.g., destination_id from Rajaongkir) */}
|
|
|
|
|
{hasPhysicalProduct && billingCustomFields.map((field: CheckoutField) => (
|
|
|
|
|
<DynamicCheckoutField
|
|
|
|
|
key={field.key}
|
|
|
|
|
field={field}
|
|
|
|
|
value={customFieldData[field.key] || ''}
|
|
|
|
|
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
|
|
|
|
countryOptions={countryOptions}
|
|
|
|
|
stateOptions={bStateOptions}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Conditional: Only show address fields and shipping for physical products */}
|
|
|
|
|
{!hasPhysicalProduct && (
|
|
|
|
|
@@ -1162,7 +1337,7 @@ export default function OrderForm({
|
|
|
|
|
<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)
|
|
|
|
|
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden && f.type !== 'hidden')
|
|
|
|
|
.sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
|
|
|
|
|
.map((field: any) => {
|
|
|
|
|
// Check for full width: address fields or form-row-wide class from PHP
|
|
|
|
|
@@ -1170,6 +1345,20 @@ export default function OrderForm({
|
|
|
|
|
const isWide = hasFormRowWide || ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
|
|
|
|
|
const fieldKey = field.key.replace('shipping_', '');
|
|
|
|
|
|
|
|
|
|
// For searchable_select, DynamicCheckoutField renders its own label wrapper
|
|
|
|
|
if (field.type === 'searchable_select') {
|
|
|
|
|
return (
|
|
|
|
|
<DynamicCheckoutField
|
|
|
|
|
key={field.key}
|
|
|
|
|
field={field}
|
|
|
|
|
value={customFieldData[field.key] || ''}
|
|
|
|
|
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
|
|
|
|
countryOptions={countryOptions}
|
|
|
|
|
stateOptions={Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }))}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
|
|
|
|
<Label>
|
|
|
|
|
@@ -1208,8 +1397,20 @@ export default function OrderForm({
|
|
|
|
|
/>
|
|
|
|
|
) : field.type === 'textarea' ? (
|
|
|
|
|
<Textarea
|
|
|
|
|
value={shippingData[fieldKey] || ''}
|
|
|
|
|
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
|
|
|
|
value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
|
|
|
|
|
onChange={(e) => field.custom
|
|
|
|
|
? handleCustomFieldChange(field.key, e.target.value)
|
|
|
|
|
: setShippingData({ ...shippingData, [fieldKey]: e.target.value })
|
|
|
|
|
}
|
|
|
|
|
placeholder={field.placeholder}
|
|
|
|
|
required={field.required}
|
|
|
|
|
/>
|
|
|
|
|
) : field.custom ? (
|
|
|
|
|
// For other custom field types, store in customFieldData
|
|
|
|
|
<Input
|
|
|
|
|
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
|
|
|
|
value={customFieldData[field.key] || ''}
|
|
|
|
|
onChange={(e) => handleCustomFieldChange(field.key, e.target.value)}
|
|
|
|
|
placeholder={field.placeholder}
|
|
|
|
|
required={field.required}
|
|
|
|
|
/>
|
|
|
|
|
|