fix: shipping cost applied to orders + dynamic field rendering
Shipping Fix: - Frontend now sends shipping_cost and shipping_title in order payload - Backend uses these values as fallback when WC zone-based rate lookup fails - Fixes issue where Rajaongkir and other API-based shipping wasn't applied Dynamic Field Rendering: - Added billingFields/shippingFields filters sorted by priority - Added getBillingField/getShippingField helpers that return undefined for hidden fields - All standard fields now conditionally rendered based on API response - Fields use labels and required flags from API - Any field can be hidden via PHP snippet (type: 'hidden' or hidden: true) - Removed unused isFieldHidden function
This commit is contained in:
@@ -222,16 +222,27 @@ export default function Checkout() {
|
|||||||
loadCheckoutFields();
|
loadCheckoutFields();
|
||||||
}, [cart.items, isVirtualOnly]);
|
}, [cart.items, isVirtualOnly]);
|
||||||
|
|
||||||
// Helper to check if a standard field should be hidden (set to type: 'hidden' or hidden: true in PHP)
|
// NOTE: Old isFieldHidden function removed - now using getBillingField/getShippingField
|
||||||
const isFieldHidden = (fieldKey: string): boolean => {
|
// which return undefined for hidden fields (type: 'hidden' or hidden: true)
|
||||||
const field = checkoutFields.find(f => f.key === fieldKey);
|
|
||||||
// Check both type='hidden' and the hidden flag from API
|
|
||||||
return field?.type === 'hidden' || field?.hidden === true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter custom fields by fieldset
|
// Get all billing fields from API (standard + custom), filtered and sorted by priority
|
||||||
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden);
|
// This allows ANY field to be hidden via PHP (type: 'hidden' or hidden: true)
|
||||||
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden);
|
const billingFields = checkoutFields
|
||||||
|
.filter(f => f.fieldset === 'billing' && f.type !== 'hidden' && !f.hidden)
|
||||||
|
.sort((a, b) => (a.priority || 10) - (b.priority || 10));
|
||||||
|
|
||||||
|
// Get all shipping fields from API (standard + custom), filtered and sorted by priority
|
||||||
|
const shippingFields = checkoutFields
|
||||||
|
.filter(f => f.fieldset === 'shipping' && f.type !== 'hidden' && !f.hidden)
|
||||||
|
.sort((a, b) => (a.priority || 10) - (b.priority || 10));
|
||||||
|
|
||||||
|
// Helper to get a billing field from API by key (for checking if it should be rendered)
|
||||||
|
const getBillingField = (key: string) => billingFields.find(f => f.key === key);
|
||||||
|
const getShippingField = (key: string) => shippingFields.find(f => f.key === key);
|
||||||
|
|
||||||
|
// Filter custom fields by fieldset (legacy support - for plugins that add non-standard fields)
|
||||||
|
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden');
|
||||||
|
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden');
|
||||||
|
|
||||||
// Handler for custom field changes
|
// Handler for custom field changes
|
||||||
const handleCustomFieldChange = (key: string, value: string) => {
|
const handleCustomFieldChange = (key: string, value: string) => {
|
||||||
@@ -533,6 +544,9 @@ export default function Checkout() {
|
|||||||
},
|
},
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
|
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
|
||||||
|
// Also send shipping cost/title for direct use when WC rate lookup fails (API-based shipping like Rajaongkir)
|
||||||
|
shipping_cost: shippingCost,
|
||||||
|
shipping_title: shippingRates.find(r => r.id === selectedShippingRate)?.label || '',
|
||||||
coupons: appliedCoupons.map(c => c.code), // Send applied coupon codes
|
coupons: appliedCoupons.map(c => c.code), // Send applied coupon codes
|
||||||
customer_note: orderNotes,
|
customer_note: orderNotes,
|
||||||
// Include all custom field data for backend processing
|
// Include all custom field data for backend processing
|
||||||
@@ -671,77 +685,89 @@ export default function Checkout() {
|
|||||||
<div className="bg-white border rounded-lg p-6">
|
<div className="bg-white border rounded-lg p-6">
|
||||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* All fields conditionally rendered based on API response */}
|
||||||
|
{/* This allows ANY field to be hidden via PHP snippet */}
|
||||||
|
{getBillingField('billing_first_name') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_first_name')?.label || 'First Name'} {getBillingField('billing_first_name')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getBillingField('billing_first_name')?.required}
|
||||||
value={billingData.firstName}
|
value={billingData.firstName}
|
||||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_last_name') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_last_name')?.label || 'Last Name'} {getBillingField('billing_last_name')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getBillingField('billing_last_name')?.required}
|
||||||
value={billingData.lastName}
|
value={billingData.lastName}
|
||||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_email') && (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_email')?.label || 'Email Address'} {getBillingField('billing_email')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required={getBillingField('billing_email')?.required}
|
||||||
value={billingData.email}
|
value={billingData.email}
|
||||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_phone') && (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_phone')?.label || 'Phone'} {getBillingField('billing_phone')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
required
|
required={getBillingField('billing_phone')?.required}
|
||||||
value={billingData.phone}
|
value={billingData.phone}
|
||||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Address fields - only for physical products */}
|
{/* Address fields - only for physical products */}
|
||||||
{!isVirtualOnly && (
|
{!isVirtualOnly && (
|
||||||
<>
|
<>
|
||||||
|
{getBillingField('billing_address_1') && (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_address_1')?.label || 'Street Address'} {getBillingField('billing_address_1')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getBillingField('billing_address_1')?.required}
|
||||||
value={billingData.address}
|
value={billingData.address}
|
||||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* City field - hidden if PHP sets type to 'hidden' */}
|
)}
|
||||||
{!isFieldHidden('billing_city') && (
|
{/* City field - conditionally rendered based on API */}
|
||||||
|
{getBillingField('billing_city') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">City *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_city')?.label || 'City'} {getBillingField('billing_city')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getBillingField('billing_city')?.required}
|
||||||
value={billingData.city}
|
value={billingData.city}
|
||||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Country field - hidden if PHP sets type to 'hidden' */}
|
{/* Country field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('billing_country') && (
|
{getBillingField('billing_country') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_country')?.label || 'Country'} {getBillingField('billing_country')?.required && '*'}</label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={countryOptions}
|
options={countryOptions}
|
||||||
value={billingData.country}
|
value={billingData.country}
|
||||||
@@ -751,10 +777,10 @@ export default function Checkout() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* State field - hidden if PHP sets type to 'hidden' */}
|
{/* State field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('billing_state') && (
|
{getBillingField('billing_state') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_state')?.label || 'State / Province'} {getBillingField('billing_state')?.required && '*'}</label>
|
||||||
{billingStateOptions.length > 0 ? (
|
{billingStateOptions.length > 0 ? (
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={billingStateOptions}
|
options={billingStateOptions}
|
||||||
@@ -773,13 +799,13 @@ export default function Checkout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Postcode field - hidden if PHP sets type to 'hidden' */}
|
{/* Postcode field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('billing_postcode') && (
|
{getBillingField('billing_postcode') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
<label className="block text-sm font-medium mb-2">{getBillingField('billing_postcode')?.label || 'Postcode / ZIP'} {getBillingField('billing_postcode')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getBillingField('billing_postcode')?.required}
|
||||||
value={billingData.postcode}
|
value={billingData.postcode}
|
||||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
@@ -919,23 +945,23 @@ export default function Checkout() {
|
|||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* City field - hidden if PHP sets type to 'hidden' */}
|
{/* City field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('shipping_city') && (
|
{getShippingField('shipping_city') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">City *</label>
|
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_city')?.label || 'City'} {getShippingField('shipping_city')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getShippingField('shipping_city')?.required}
|
||||||
value={shippingData.city}
|
value={shippingData.city}
|
||||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Country field - hidden if PHP sets type to 'hidden' */}
|
{/* Country field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('shipping_country') && (
|
{getShippingField('shipping_country') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_country')?.label || 'Country'} {getShippingField('shipping_country')?.required && '*'}</label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={countryOptions}
|
options={countryOptions}
|
||||||
value={shippingData.country}
|
value={shippingData.country}
|
||||||
@@ -945,10 +971,10 @@ export default function Checkout() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* State field - hidden if PHP sets type to 'hidden' */}
|
{/* State field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('shipping_state') && (
|
{getShippingField('shipping_state') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_state')?.label || 'State / Province'} {getShippingField('shipping_state')?.required && '*'}</label>
|
||||||
{shippingStateOptions.length > 0 ? (
|
{shippingStateOptions.length > 0 ? (
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={shippingStateOptions}
|
options={shippingStateOptions}
|
||||||
@@ -967,13 +993,13 @@ export default function Checkout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Postcode field - hidden if PHP sets type to 'hidden' */}
|
{/* Postcode field - conditionally rendered based on API */}
|
||||||
{!isFieldHidden('shipping_postcode') && (
|
{getShippingField('shipping_postcode') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_postcode')?.label || 'Postcode / ZIP'} {getShippingField('shipping_postcode')?.required && '*'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required={getShippingField('shipping_postcode')?.required}
|
||||||
value={shippingData.postcode}
|
value={shippingData.postcode}
|
||||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
|||||||
@@ -426,6 +426,23 @@ class CheckoutController {
|
|||||||
'taxes' => $rate->get_taxes(),
|
'taxes' => $rate->get_taxes(),
|
||||||
]);
|
]);
|
||||||
$order->add_item($item);
|
$order->add_item($item);
|
||||||
|
} elseif (!empty($payload['shipping_cost']) && $payload['shipping_cost'] > 0) {
|
||||||
|
// Fallback: use shipping_cost directly from frontend
|
||||||
|
// This handles API-based shipping like Rajaongkir where WC zones don't apply
|
||||||
|
$item = new \WC_Order_Item_Shipping();
|
||||||
|
|
||||||
|
// Parse method ID from shipping_method (format: "method_id:instance_id" or "method_id:instance_id:variant")
|
||||||
|
$parts = explode(':', $payload['shipping_method']);
|
||||||
|
$method_id = $parts[0] ?? 'shipping';
|
||||||
|
$instance_id = isset($parts[1]) ? (int)$parts[1] : 0;
|
||||||
|
|
||||||
|
$item->set_props([
|
||||||
|
'method_title' => sanitize_text_field($payload['shipping_title'] ?? 'Shipping'),
|
||||||
|
'method_id' => sanitize_text_field($method_id),
|
||||||
|
'instance_id' => $instance_id,
|
||||||
|
'total' => floatval($payload['shipping_cost']),
|
||||||
|
]);
|
||||||
|
$order->add_item($item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user