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();
|
||||
}, [cart.items, isVirtualOnly]);
|
||||
|
||||
// Helper to check if a standard field should be hidden (set to type: 'hidden' or hidden: true in PHP)
|
||||
const isFieldHidden = (fieldKey: string): boolean => {
|
||||
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;
|
||||
};
|
||||
// NOTE: Old isFieldHidden function removed - now using getBillingField/getShippingField
|
||||
// which return undefined for hidden fields (type: 'hidden' or hidden: true)
|
||||
|
||||
// Filter custom fields by fieldset
|
||||
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden);
|
||||
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden);
|
||||
// Get all billing fields from API (standard + custom), filtered and sorted by priority
|
||||
// This allows ANY field to be hidden via PHP (type: 'hidden' or hidden: true)
|
||||
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
|
||||
const handleCustomFieldChange = (key: string, value: string) => {
|
||||
@@ -533,6 +544,9 @@ export default function Checkout() {
|
||||
},
|
||||
payment_method: paymentMethod,
|
||||
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
|
||||
customer_note: orderNotes,
|
||||
// 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">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
{/* All fields conditionally rendered based on API response */}
|
||||
{/* This allows ANY field to be hidden via PHP snippet */}
|
||||
{getBillingField('billing_first_name') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_first_name')?.label || 'First Name'} {getBillingField('billing_first_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required={getBillingField('billing_first_name')?.required}
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_last_name') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_last_name')?.label || 'Last Name'} {getBillingField('billing_last_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required={getBillingField('billing_last_name')?.required}
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_email') && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_email')?.label || 'Email Address'} {getBillingField('billing_email')?.required && '*'}</label>
|
||||
<input
|
||||
type="email"
|
||||
required={getBillingField('billing_email')?.required}
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_phone') && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_phone')?.label || 'Phone'} {getBillingField('billing_phone')?.required && '*'}</label>
|
||||
<input
|
||||
type="tel"
|
||||
required={getBillingField('billing_phone')?.required}
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address fields - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
{/* City field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('billing_city') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
{getBillingField('billing_address_1') && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_address_1')?.label || 'Street Address'} {getBillingField('billing_address_1')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_address_1')?.required}
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* City field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_city') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_city')?.label || 'City'} {getBillingField('billing_city')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required={getBillingField('billing_city')?.required}
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Country field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('billing_country') && (
|
||||
{/* Country field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_country') && (
|
||||
<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
|
||||
options={countryOptions}
|
||||
value={billingData.country}
|
||||
@@ -751,10 +777,10 @@ export default function Checkout() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* State field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('billing_state') && (
|
||||
{/* State field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_state') && (
|
||||
<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 ? (
|
||||
<SearchableSelect
|
||||
options={billingStateOptions}
|
||||
@@ -773,13 +799,13 @@ export default function Checkout() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Postcode field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('billing_postcode') && (
|
||||
{/* Postcode field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_postcode') && (
|
||||
<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
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_postcode')?.required}
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{/* City field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('shipping_city') && (
|
||||
{/* City field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_city') && (
|
||||
<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
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_city')?.required}
|
||||
value={shippingData.city}
|
||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Country field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('shipping_country') && (
|
||||
{/* Country field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_country') && (
|
||||
<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
|
||||
options={countryOptions}
|
||||
value={shippingData.country}
|
||||
@@ -945,10 +971,10 @@ export default function Checkout() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* State field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('shipping_state') && (
|
||||
{/* State field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_state') && (
|
||||
<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 ? (
|
||||
<SearchableSelect
|
||||
options={shippingStateOptions}
|
||||
@@ -967,13 +993,13 @@ export default function Checkout() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Postcode field - hidden if PHP sets type to 'hidden' */}
|
||||
{!isFieldHidden('shipping_postcode') && (
|
||||
{/* Postcode field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_postcode') && (
|
||||
<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
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_postcode')?.required}
|
||||
value={shippingData.postcode}
|
||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
|
||||
@@ -426,6 +426,23 @@ class CheckoutController {
|
||||
'taxes' => $rate->get_taxes(),
|
||||
]);
|
||||
$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