Fix RajaOngkir address integration bugs and styling issues

This commit is contained in:
Dwindi Ramadhana
2026-06-03 22:56:02 +07:00
parent 21ece27b9b
commit fb1a6c40ef
7 changed files with 263 additions and 21 deletions

View File

@@ -18,6 +18,7 @@ interface Address {
email?: string; email?: string;
phone?: string; phone?: string;
is_default: boolean; is_default: boolean;
formatted_address?: string;
} }
interface AddressSelectorProps { interface AddressSelectorProps {
@@ -148,14 +149,22 @@ export function AddressSelector({
)} )}
{/* Address */} {/* Address */}
<p className="text-sm text-gray-600 mt-2"> <div className="text-sm text-gray-600 mt-2">
{address.address_1} {address.formatted_address ? (
{address.address_2 && `, ${address.address_2}`} <p className="whitespace-pre-wrap">{address.formatted_address}</p>
</p> ) : (
<p className="text-sm text-gray-600"> <>
{address.city}, {address.state} {address.postcode} <p>
</p> {address.address_1}
<p className="text-sm text-gray-600">{address.country}</p> {address.address_2 && `, ${address.address_2}`}
</p>
<p>
{address.city}, {address.state} {address.postcode}
</p>
<p>{address.country}</p>
</>
)}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -28,6 +28,7 @@ interface CheckoutField {
interface DynamicCheckoutFieldProps { interface DynamicCheckoutFieldProps {
field: CheckoutField; field: CheckoutField;
value: string; value: string;
valueLabel?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
countryOptions?: { value: string; label: string }[]; countryOptions?: { value: string; label: string }[];
stateOptions?: { value: string; label: string }[]; stateOptions?: { value: string; label: string }[];
@@ -41,6 +42,7 @@ interface SearchOption {
export function DynamicCheckoutField({ export function DynamicCheckoutField({
field, field,
value, value,
valueLabel,
onChange, onChange,
countryOptions = [], countryOptions = [],
stateOptions = [], stateOptions = [],
@@ -54,9 +56,11 @@ export function DynamicCheckoutField({
return; return;
} }
// If we have a value but no options yet, we might need to load it // If we have a value and a label, inject it into searchOptions so it renders properly when mounted
// This handles pre-selected values if (value && valueLabel && searchOptions.length === 0) {
}, [field.type, field.search_endpoint, value]); setSearchOptions([{ value, label: valueLabel }]);
}
}, [field.type, field.search_endpoint, value, valueLabel]);
// Handle API search for searchable_select // Handle API search for searchable_select
const handleApiSearch = async (searchTerm: string) => { const handleApiSearch = async (searchTerm: string) => {

View File

@@ -345,10 +345,18 @@ export default function Addresses() {
<div className="text-sm text-gray-700 space-y-1 mb-4"> <div className="text-sm text-gray-700 space-y-1 mb-4">
<p className="font-medium">{address.first_name} {address.last_name}</p> <p className="font-medium">{address.first_name} {address.last_name}</p>
{address.company && <p>{address.company}</p>} {address.company && <p>{address.company}</p>}
<p>{address.address_1}</p> {address.formatted_address ? (
{address.address_2 && <p>{address.address_2}</p>} <p className="whitespace-pre-wrap">{address.formatted_address}</p>
<p>{address.city}, {address.state} {address.postcode}</p> ) : (
<p>{address.country}</p> <>
<p>{address.address_1}</p>
{address.address_2 && <p>{address.address_2}</p>}
{[address.city, address.state, address.postcode].filter(Boolean).length > 0 && (
<p>{[address.city, address.state, address.postcode].filter(Boolean).join(', ')}</p>
)}
<p>{address.country}</p>
</>
)}
{address.phone && <p className="pt-2">Phone: {address.phone}</p>} {address.phone && <p className="pt-2">Phone: {address.phone}</p>}
{address.email && <p>Email: {address.email}</p>} {address.email && <p>Email: {address.email}</p>}
</div> </div>
@@ -429,6 +437,7 @@ export default function Addresses() {
key={field.key} key={field.key}
field={field} field={field}
value={getFieldValue(field.key)} value={getFieldValue(field.key)}
valueLabel={getFieldValue(field.key + '_label')}
onChange={(v) => setFieldValue(field.key, v)} onChange={(v) => setFieldValue(field.key, v)}
countryOptions={countryOptions} countryOptions={countryOptions}
stateOptions={stateOptions} stateOptions={stateOptions}

View File

@@ -33,6 +33,7 @@ interface SavedAddress {
email?: string; email?: string;
phone?: string; phone?: string;
is_default: boolean; is_default: boolean;
formatted_address?: string;
} }
export default function Checkout() { export default function Checkout() {
@@ -389,7 +390,13 @@ export default function Checkout() {
state: addressData.state, state: addressData.state,
city: addressData.city, city: addressData.city,
postcode: addressData.postcode, postcode: addressData.postcode,
destination_id: undefined, // Include custom fields for shipping calculation (e.g., RajaOngkir destination_id)
...Object.fromEntries(
(shipToDifferentAddress ? shippingCustomFields : billingCustomFields).map(f => [
f.key.replace(/^(shipping_|billing_)/, ''),
customFieldData[f.key] || ''
])
),
}, },
items, items,
}); });
@@ -795,7 +802,16 @@ export default function Checkout() {
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm"> <div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
<p className="font-semibold">{sel.label}{sel.is_default && <span className="ml-2 text-xs bg-green-100 text-green-700 px-1.5 rounded">Default</span>}</p> <p className="font-semibold">{sel.label}{sel.is_default && <span className="ml-2 text-xs bg-green-100 text-green-700 px-1.5 rounded">Default</span>}</p>
<p>{sel.first_name} {sel.last_name}</p> <p>{sel.first_name} {sel.last_name}</p>
<p className="text-gray-600">{sel.address_1}, {sel.city}, {sel.state} {sel.postcode}</p> {sel.formatted_address ? (
<p className="text-gray-600 whitespace-pre-wrap">{sel.formatted_address}</p>
) : (
<div className="text-gray-600">
<p>{sel.address_1}</p>
{[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
<p>{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}</p>
)}
</div>
)}
</div> </div>
) : null; ) : null;
})()} })()}
@@ -866,7 +882,7 @@ export default function Checkout() {
</div> </div>
)} )}
{billingCustomFields.map(field => ( {billingCustomFields.map(field => (
<DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} /> <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} valueLabel={customFieldData[`${field.key}_label`]} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
))} ))}
</> </>
)} )}
@@ -908,7 +924,16 @@ export default function Checkout() {
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm"> <div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
<p className="font-semibold">{sel.label}</p> <p className="font-semibold">{sel.label}</p>
<p>{sel.first_name} {sel.last_name}</p> <p>{sel.first_name} {sel.last_name}</p>
<p className="text-gray-600">{sel.address_1}, {sel.city} {sel.postcode}</p> {sel.formatted_address ? (
<p className="text-gray-600 whitespace-pre-wrap">{sel.formatted_address}</p>
) : (
<div className="text-gray-600">
<p>{sel.address_1}</p>
{[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
<p>{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}</p>
)}
</div>
)}
</div> </div>
) : null; ) : null;
})()} })()}
@@ -926,7 +951,7 @@ export default function Checkout() {
{getShippingField('shipping_country') && <div><label className="block text-sm font-medium mb-1">Country</label><SearchableSelect options={countryOptions} value={shippingData.country} onChange={v => setShippingData({ ...shippingData, country: v })} placeholder="Select country" disabled={countries.length === 1} /></div>} {getShippingField('shipping_country') && <div><label className="block text-sm font-medium mb-1">Country</label><SearchableSelect options={countryOptions} value={shippingData.country} onChange={v => setShippingData({ ...shippingData, country: v })} placeholder="Select country" disabled={countries.length === 1} /></div>}
{getShippingField('shipping_state') && <div><label className="block text-sm font-medium mb-1">State</label>{shippingStateOptions.length > 0 ? <SearchableSelect options={shippingStateOptions} value={shippingData.state} onChange={v => setShippingData({ ...shippingData, state: v })} placeholder="Select state" /> : <input type="text" value={shippingData.state} onChange={e => setShippingData({ ...shippingData, state: e.target.value })} className="w-full border rounded-lg px-4 py-2" />}</div>} {getShippingField('shipping_state') && <div><label className="block text-sm font-medium mb-1">State</label>{shippingStateOptions.length > 0 ? <SearchableSelect options={shippingStateOptions} value={shippingData.state} onChange={v => setShippingData({ ...shippingData, state: v })} placeholder="Select state" /> : <input type="text" value={shippingData.state} onChange={e => setShippingData({ ...shippingData, state: e.target.value })} className="w-full border rounded-lg px-4 py-2" />}</div>}
{getShippingField('shipping_postcode') && <div><label className="block text-sm font-medium mb-1">Postcode</label><input type="text" value={shippingData.postcode} onChange={e => setShippingData({ ...shippingData, postcode: e.target.value })} className="w-full border rounded-lg px-4 py-2" /></div>} {getShippingField('shipping_postcode') && <div><label className="block text-sm font-medium mb-1">Postcode</label><input type="text" value={shippingData.postcode} onChange={e => setShippingData({ ...shippingData, postcode: e.target.value })} className="w-full border rounded-lg px-4 py-2" /></div>}
{shippingCustomFields.map(field => <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)} {shippingCustomFields.map(field => <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} valueLabel={customFieldData[`${field.key}_label`]} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
</div> </div>
)} )}
</> </>

View File

@@ -538,6 +538,9 @@ class CheckoutController
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1)); header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
} }
// Auto-save checkout addresses to user account
$this->auto_save_checkout_addresses($order, $payload);
// Clear WooCommerce cart after successful order placement // Clear WooCommerce cart after successful order placement
// This ensures the cart page won't re-populate from server session // This ensures the cart page won't re-populate from server session
if (function_exists('WC') && WC()->cart) { if (function_exists('WC') && WC()->cart) {
@@ -1141,4 +1144,105 @@ class CheckoutController
} }
return $results; return $results;
} }
/**
* Auto-save checkout addresses to the user's address book if they are new.
*/
private function auto_save_checkout_addresses(\WC_Order $order, array $payload): void
{
$user_id = $order->get_customer_id();
if (!$user_id) {
return;
}
$addresses = get_user_meta($user_id, 'woonoow_addresses', true);
if (!is_array($addresses)) {
$addresses = [];
}
// Helper to check if address matches existing
$is_duplicate = function ($new_addr, $type) use ($addresses) {
foreach ($addresses as $addr) {
if ($addr['type'] !== $type && $addr['type'] !== 'both') {
continue;
}
// Compare essential fields
$match = true;
$check_fields = ['first_name', 'last_name', 'address_1', 'city', 'country'];
foreach ($check_fields as $f) {
$v1 = trim(strtolower((string)($addr[$f] ?? '')));
$v2 = trim(strtolower((string)($new_addr[$f] ?? '')));
if ($v1 !== $v2) {
$match = false;
break;
}
}
if ($match) return true;
}
return false;
};
// Helper to build address array
$build_address = function ($type, $data, $custom_fields, $addresses) {
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
$addr = [
'id' => $new_id,
'label' => ucfirst($type) . ' ' . $new_id,
'type' => $type,
'is_default' => empty($addresses), // default if it's the first one
];
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
foreach ($standard_fields as $f) {
if (isset($data[$f])) {
$addr[$f] = sanitize_text_field($data[$f]);
}
}
// Add custom fields matching prefix
if (is_array($custom_fields)) {
foreach ($custom_fields as $k => $v) {
if (strpos($k, $type . '_') === 0) {
$addr[$k] = sanitize_text_field($v);
} elseif (!isset($addr[$type . '_' . $k]) && !isset($addr[$k])) {
// Some custom fields might not have the prefix if they apply to both
// Or they are sent without prefix by frontend in payload[type]
}
}
}
// Also, payload[type] can contain custom fields directly because frontend sends them without prefix!
foreach ($data as $k => $v) {
if (!in_array($k, $standard_fields) && !in_array($k, ['ship_to_different'])) {
$addr[$type . '_' . $k] = sanitize_text_field($v);
}
}
return $addr;
};
$changed = false;
// Check billing
if (!empty($payload['billing'])) {
if (!$is_duplicate($payload['billing'], 'billing')) {
$billing_addr = $build_address('billing', $payload['billing'], $payload['custom_fields'] ?? [], $addresses);
$addresses[] = $billing_addr;
$changed = true;
}
}
// Check shipping
$ship_to_different = !empty($payload['shipping']['ship_to_different']);
if ($ship_to_different && !empty($payload['shipping'])) {
if (!$is_duplicate($payload['shipping'], 'shipping')) {
$shipping_addr = $build_address('shipping', $payload['shipping'], $payload['custom_fields'] ?? [], $addresses);
$addresses[] = $shipping_addr;
$changed = true;
}
}
if ($changed) {
update_user_meta($user_id, 'woonoow_addresses', array_values($addresses));
}
}
} }

View File

@@ -69,6 +69,10 @@ class AddressController {
$addresses = array_values($addresses); $addresses = array_values($addresses);
foreach ($addresses as &$address) {
$address['formatted_address'] = apply_filters('woonoow_format_address', '', $address);
}
return new WP_REST_Response($addresses, 200); return new WP_REST_Response($addresses, 200);
} }

View File

@@ -91,6 +91,11 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
$fields['billing']['billing_last_name']['type'] = 'hidden'; $fields['billing']['billing_last_name']['type'] = 'hidden';
$fields['billing']['billing_last_name']['default'] = 'ID'; $fields['billing']['billing_last_name']['default'] = 'ID';
$fields['billing']['billing_last_name']['required'] = false; $fields['billing']['billing_last_name']['required'] = false;
// Make first_name take full width since last_name is hidden
if (isset($fields['billing']['billing_first_name'])) {
$fields['billing']['billing_first_name']['class'] = ['form-row-wide'];
}
} }
if (isset($fields['billing']['billing_country'])) { if (isset($fields['billing']['billing_country'])) {
$fields['billing']['billing_country']['type'] = 'hidden'; $fields['billing']['billing_country']['type'] = 'hidden';
@@ -115,6 +120,11 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
$fields['shipping']['shipping_last_name']['type'] = 'hidden'; $fields['shipping']['shipping_last_name']['type'] = 'hidden';
$fields['shipping']['shipping_last_name']['default'] = 'ID'; $fields['shipping']['shipping_last_name']['default'] = 'ID';
$fields['shipping']['shipping_last_name']['required'] = false; $fields['shipping']['shipping_last_name']['required'] = false;
// Make first_name take full width since last_name is hidden
if (isset($fields['shipping']['shipping_first_name'])) {
$fields['shipping']['shipping_first_name']['class'] = ['form-row-wide'];
}
} }
if (isset($fields['shipping']['shipping_country'])) { if (isset($fields['shipping']['shipping_country'])) {
$fields['shipping']['shipping_country']['type'] = 'hidden'; $fields['shipping']['shipping_country']['type'] = 'hidden';
@@ -137,7 +147,8 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
// Check if cart needs shipping // Check if cart needs shipping
$needs_shipping = true; $needs_shipping = true;
if (function_exists('WC') && WC()->cart) { // If cart is empty, we assume it's for Address Book in My Account where we want fields visible
if (function_exists('WC') && WC()->cart && !WC()->cart->is_empty()) {
$needs_shipping = WC()->cart->needs_shipping(); $needs_shipping = WC()->cart->needs_shipping();
} }
@@ -225,3 +236,79 @@ add_action('woonoow/shipping/before_calculate', function ($shipping, $items) {
// Clear shipping cache to force recalculation // Clear shipping cache to force recalculation
WC()->session->set('shipping_for_package_0', false); WC()->session->set('shipping_for_package_0', false);
}, 10, 2); }, 10, 2);
// ============================================================
// 4. Save destination_id to Order Meta on SPA Checkout
// ============================================================
add_action('woocommerce_checkout_order_processed', function ($order_id, $payload) {
// Extract and save destination_id from shipping payload
if (!empty($payload['shipping']['destination_id'])) {
update_post_meta($order_id, '_shipping_destination_id', sanitize_text_field($payload['shipping']['destination_id']));
}
// Extract and save destination_id from billing payload
if (!empty($payload['billing']['destination_id'])) {
update_post_meta($order_id, '_billing_destination_id', sanitize_text_field($payload['billing']['destination_id']));
}
// Fallback to custom_fields array if present
if (!empty($payload['custom_fields']['shipping_destination_id'])) {
update_post_meta($order_id, '_shipping_destination_id', sanitize_text_field($payload['custom_fields']['shipping_destination_id']));
}
if (!empty($payload['custom_fields']['billing_destination_id'])) {
update_post_meta($order_id, '_billing_destination_id', sanitize_text_field($payload['custom_fields']['billing_destination_id']));
}
// Save labels too if they exist, useful for backend viewing
if (!empty($payload['custom_fields']['shipping_destination_id_label'])) {
update_post_meta($order_id, '_shipping_destination_id_label', sanitize_text_field($payload['custom_fields']['shipping_destination_id_label']));
}
if (!empty($payload['custom_fields']['billing_destination_id_label'])) {
update_post_meta($order_id, '_billing_destination_id_label', sanitize_text_field($payload['custom_fields']['billing_destination_id_label']));
}
}, 10, 2);
// ============================================================
// 5. Format address display for SPA saved addresses
// ============================================================
add_filter('woonoow_format_address', function ($formatted, $address) {
// If a snippet has already formatted it, skip
if (!empty($formatted)) {
return $formatted;
}
$type = $address['type'] ?? 'billing';
$is_billing = $type === 'billing' || $type === 'both';
// Look for destination_id_label
$label = '';
if (!empty($address['destination_id_label'])) {
$label = $address['destination_id_label'];
} elseif ($is_billing && !empty($address['billing_destination_id_label'])) {
$label = $address['billing_destination_id_label'];
} elseif (!$is_billing && !empty($address['shipping_destination_id_label'])) {
$label = $address['shipping_destination_id_label'];
}
// If we have a Rajaongkir label, construct a clean Indonesian address
if ($label) {
$parts = [];
if (!empty($address['address_1'])) {
$parts[] = $address['address_1'];
}
if (!empty($address['address_2'])) {
$parts[] = $address['address_2'];
}
// Append the Rajaongkir province/city/subdistrict string
$parts[] = $label;
if (!empty($address['postcode'])) {
$parts[] = $address['postcode'];
}
// The SPA uses whitespace-pre-wrap so newline \n works for visual separation
return implode("\n", $parts);
}
return $formatted;
}, 10, 2);