diff --git a/customer-spa/src/components/AddressSelector.tsx b/customer-spa/src/components/AddressSelector.tsx
index 3411a99..529d6bb 100644
--- a/customer-spa/src/components/AddressSelector.tsx
+++ b/customer-spa/src/components/AddressSelector.tsx
@@ -18,6 +18,7 @@ interface Address {
email?: string;
phone?: string;
is_default: boolean;
+ formatted_address?: string;
}
interface AddressSelectorProps {
@@ -148,14 +149,22 @@ export function AddressSelector({
)}
{/* Address */}
-
- {address.address_1}
- {address.address_2 && `, ${address.address_2}`}
-
-
- {address.city}, {address.state} {address.postcode}
-
- {address.country}
+
+ {address.formatted_address ? (
+
{address.formatted_address}
+ ) : (
+ <>
+
+ {address.address_1}
+ {address.address_2 && `, ${address.address_2}`}
+
+
+ {address.city}, {address.state} {address.postcode}
+
+
{address.country}
+ >
+ )}
+
))}
diff --git a/customer-spa/src/components/DynamicCheckoutField.tsx b/customer-spa/src/components/DynamicCheckoutField.tsx
index 0107a0f..80e6085 100644
--- a/customer-spa/src/components/DynamicCheckoutField.tsx
+++ b/customer-spa/src/components/DynamicCheckoutField.tsx
@@ -28,6 +28,7 @@ interface CheckoutField {
interface DynamicCheckoutFieldProps {
field: CheckoutField;
value: string;
+ valueLabel?: string;
onChange: (value: string) => void;
countryOptions?: { value: string; label: string }[];
stateOptions?: { value: string; label: string }[];
@@ -41,6 +42,7 @@ interface SearchOption {
export function DynamicCheckoutField({
field,
value,
+ valueLabel,
onChange,
countryOptions = [],
stateOptions = [],
@@ -54,9 +56,11 @@ export function DynamicCheckoutField({
return;
}
- // If we have a value but no options yet, we might need to load it
- // This handles pre-selected values
- }, [field.type, field.search_endpoint, value]);
+ // If we have a value and a label, inject it into searchOptions so it renders properly when mounted
+ if (value && valueLabel && searchOptions.length === 0) {
+ setSearchOptions([{ value, label: valueLabel }]);
+ }
+ }, [field.type, field.search_endpoint, value, valueLabel]);
// Handle API search for searchable_select
const handleApiSearch = async (searchTerm: string) => {
diff --git a/customer-spa/src/pages/Account/Addresses.tsx b/customer-spa/src/pages/Account/Addresses.tsx
index 41c0bf7..879b6fd 100644
--- a/customer-spa/src/pages/Account/Addresses.tsx
+++ b/customer-spa/src/pages/Account/Addresses.tsx
@@ -345,10 +345,18 @@ export default function Addresses() {
{address.first_name} {address.last_name}
{address.company &&
{address.company}
}
-
{address.address_1}
- {address.address_2 &&
{address.address_2}
}
-
{address.city}, {address.state} {address.postcode}
-
{address.country}
+ {address.formatted_address ? (
+
{address.formatted_address}
+ ) : (
+ <>
+
{address.address_1}
+ {address.address_2 &&
{address.address_2}
}
+ {[address.city, address.state, address.postcode].filter(Boolean).length > 0 && (
+
{[address.city, address.state, address.postcode].filter(Boolean).join(', ')}
+ )}
+
{address.country}
+ >
+ )}
{address.phone &&
Phone: {address.phone}
}
{address.email &&
Email: {address.email}
}
@@ -429,6 +437,7 @@ export default function Addresses() {
key={field.key}
field={field}
value={getFieldValue(field.key)}
+ valueLabel={getFieldValue(field.key + '_label')}
onChange={(v) => setFieldValue(field.key, v)}
countryOptions={countryOptions}
stateOptions={stateOptions}
diff --git a/customer-spa/src/pages/Checkout/index.tsx b/customer-spa/src/pages/Checkout/index.tsx
index 689baad..9ee4bdd 100644
--- a/customer-spa/src/pages/Checkout/index.tsx
+++ b/customer-spa/src/pages/Checkout/index.tsx
@@ -33,6 +33,7 @@ interface SavedAddress {
email?: string;
phone?: string;
is_default: boolean;
+ formatted_address?: string;
}
export default function Checkout() {
@@ -389,7 +390,13 @@ export default function Checkout() {
state: addressData.state,
city: addressData.city,
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,
});
@@ -795,7 +802,16 @@ export default function Checkout() {
{sel.label}{sel.is_default && Default}
{sel.first_name} {sel.last_name}
-
{sel.address_1}, {sel.city}, {sel.state} {sel.postcode}
+ {sel.formatted_address ? (
+
{sel.formatted_address}
+ ) : (
+
+
{sel.address_1}
+ {[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
+
{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}
+ )}
+
+ )}
) : null;
})()}
@@ -866,7 +882,7 @@ export default function Checkout() {
)}
{billingCustomFields.map(field => (
- handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
+ handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
))}
>
)}
@@ -908,7 +924,16 @@ export default function Checkout() {
{sel.label}
{sel.first_name} {sel.last_name}
-
{sel.address_1}, {sel.city} {sel.postcode}
+ {sel.formatted_address ? (
+
{sel.formatted_address}
+ ) : (
+
+
{sel.address_1}
+ {[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
+
{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}
+ )}
+
+ )}
) : null;
})()}
@@ -926,7 +951,7 @@ export default function Checkout() {
{getShippingField('shipping_country') && setShippingData({ ...shippingData, country: v })} placeholder="Select country" disabled={countries.length === 1} />
}
{getShippingField('shipping_state') && {shippingStateOptions.length > 0 ? setShippingData({ ...shippingData, state: v })} placeholder="Select state" /> : setShippingData({ ...shippingData, state: e.target.value })} className="w-full border rounded-lg px-4 py-2" />}
}
{getShippingField('shipping_postcode') && setShippingData({ ...shippingData, postcode: e.target.value })} className="w-full border rounded-lg px-4 py-2" />
}
- {shippingCustomFields.map(field => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
+ {shippingCustomFields.map(field => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
)}
>
diff --git a/includes/Api/CheckoutController.php b/includes/Api/CheckoutController.php
index 8edadbd..229b157 100644
--- a/includes/Api/CheckoutController.php
+++ b/includes/Api/CheckoutController.php
@@ -538,6 +538,9 @@ class CheckoutController
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
// This ensures the cart page won't re-populate from server session
if (function_exists('WC') && WC()->cart) {
@@ -1141,4 +1144,105 @@ class CheckoutController
}
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));
+ }
+ }
}
diff --git a/includes/Frontend/AddressController.php b/includes/Frontend/AddressController.php
index ff48821..d92944e 100644
--- a/includes/Frontend/AddressController.php
+++ b/includes/Frontend/AddressController.php
@@ -69,6 +69,10 @@ class AddressController {
$addresses = array_values($addresses);
+ foreach ($addresses as &$address) {
+ $address['formatted_address'] = apply_filters('woonoow_format_address', '', $address);
+ }
+
return new WP_REST_Response($addresses, 200);
}
diff --git a/snippets/rajaongkir-x-woonoow.php b/snippets/rajaongkir-x-woonoow.php
index 3f6b5e3..d00ad4f 100644
--- a/snippets/rajaongkir-x-woonoow.php
+++ b/snippets/rajaongkir-x-woonoow.php
@@ -91,6 +91,11 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
$fields['billing']['billing_last_name']['type'] = 'hidden';
$fields['billing']['billing_last_name']['default'] = 'ID';
$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'])) {
$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']['default'] = 'ID';
$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'])) {
$fields['shipping']['shipping_country']['type'] = 'hidden';
@@ -137,7 +147,8 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
// Check if cart needs shipping
$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();
}
@@ -225,3 +236,79 @@ add_action('woonoow/shipping/before_calculate', function ($shipping, $items) {
// Clear shipping cache to force recalculation
WC()->session->set('shipping_for_package_0', false);
}, 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);