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:
Dwindi Ramadhana
2026-01-08 23:41:30 +07:00
parent 7a45b243cb
commit c6489b6b05
2 changed files with 131 additions and 88 deletions

View File

@@ -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">
{/* 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">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
type="text"
required
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">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
type="text"
required
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">Email Address *</label>
<label className="block text-sm font-medium mb-2">{getBillingField('billing_email')?.label || 'Email Address'} {getBillingField('billing_email')?.required && '*'}</label>
<input
type="email"
required
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">Phone *</label>
<label className="block text-sm font-medium mb-2">{getBillingField('billing_phone')?.label || 'Phone'} {getBillingField('billing_phone')?.required && '*'}</label>
<input
type="tel"
required
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 && (
<>
{getBillingField('billing_address_1') && (
<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
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 - hidden if PHP sets type to 'hidden' */}
{!isFieldHidden('billing_city') && (
)}
{/* City field - conditionally rendered based on API */}
{getBillingField('billing_city') && (
<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
type="text"
required
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"

View File

@@ -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);
}
}