feat(checkout): searchable address fields and Rajaongkir integration

Admin SPA:
- Changed billing/shipping state from Select to SearchableSelect

Customer SPA:
- Added cmdk package for command palette
- Created popover, command, and searchable-select UI components
- Added searchable country and state fields to checkout
- Fetches countries/states from /countries API
- Auto-clears state when country changes

Backend:
- Added generic woonoow/shipping/before_calculate hook
- Removed hardcoded Rajaongkir session handling

Documentation:
- Updated RAJAONGKIR_INTEGRATION.md with:
  - Complete searchable destination selector plugin code
  - JavaScript implementation
  - React component version
  - REST API endpoint for destination search
This commit is contained in:
Dwindi Ramadhana
2026-01-08 11:19:37 +07:00
parent 786e01c8f6
commit 2939ebfe6b
7 changed files with 730 additions and 127 deletions

View File

@@ -37,133 +37,433 @@ do_action( 'woonoow/shipping/before_calculate', $shipping_data, $items );
- `city` - City name
- `postcode` - Postal code
- `address_1` - Street address
- `address_2` - Additional address (optional)
- `destination_id` - Custom field for Rajaongkir (added via addon)
- + Any custom fields added by addons
- `$items` (array) - Cart items being shipped
---
## Code Snippet: Rajaongkir Bridge
## Complete Integration: Searchable Destination Selector
Add this code to your theme's `functions.php` or via a code snippets plugin:
This is a complete code solution that adds a searchable Rajaongkir destination selector to WooNooW checkout.
### Plugin File: `woonoow-rajaongkir-bridge.php`
Create this as a new plugin or add to your theme's functions.php:
```php
<?php
/**
* Bridge Rajaongkir plugin with WooNooW shipping calculation.
*
* This code listens for WooNooW's shipping hook and sets the
* Rajaongkir session variables it needs to calculate rates.
* Plugin Name: WooNooW Rajaongkir Bridge
* Description: Adds searchable destination selector for Rajaongkir shipping
* Version: 1.0.0
* Requires Plugins: woonoow, cekongkir
*/
if ( ! defined( 'ABSPATH' ) ) exit;
/**
* 1. Hook into WooNooW shipping calculation to set Rajaongkir session
*/
add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
// Only process for Indonesia
if ( empty( $shipping['country'] ) || $shipping['country'] !== 'ID' ) {
// Clear Rajaongkir session for non-ID countries
WC()->session->__unset( 'selected_destination_id' );
WC()->session->__unset( 'selected_destination_label' );
return;
}
// Check if destination_id is provided by frontend
// Set destination from frontend
if ( ! empty( $shipping['destination_id'] ) ) {
WC()->session->set( 'selected_destination_id', $shipping['destination_id'] );
WC()->session->set( 'selected_destination_label', $shipping['destination_label'] ?? $shipping['city'] );
return;
}
// Fallback: Try to lookup destination from city name
// This requires the Rajaongkir database lookup
$destination = rajaongkir_lookup_destination( $shipping['city'], $shipping['state'] );
if ( $destination ) {
WC()->session->set( 'selected_destination_id', $destination['id'] );
WC()->session->set( 'selected_destination_label', $destination['label'] );
WC()->session->set( 'selected_destination_id', intval( $shipping['destination_id'] ) );
WC()->session->set( 'selected_destination_label', sanitize_text_field( $shipping['destination_label'] ?? '' ) );
}
}, 10, 2 );
/**
* Helper: Lookup Rajaongkir destination by city/state name.
*
* This is a simplified example. Implement based on your Rajaongkir data structure.
* 2. REST API: Search Rajaongkir destinations
*/
function rajaongkir_lookup_destination( $city, $state ) {
// Query the Rajaongkir location database
// This depends on how your Rajaongkir plugin stores location data
add_action( 'rest_api_init', function() {
register_rest_route( 'woonoow/v1', '/rajaongkir/destinations', [
'methods' => 'GET',
'callback' => 'woonoow_search_rajaongkir_destinations',
'permission_callback' => '__return_true',
'args' => [
'search' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
function woonoow_search_rajaongkir_destinations( $request ) {
$search = $request->get_param( 'search' );
// Example using transient/option storage:
$locations = get_option( 'cekongkir_destinations', [] );
if ( strlen( $search ) < 2 ) {
return new WP_REST_Response( [], 200 );
}
// Get Rajaongkir destinations from transient/cache
$destinations = get_transient( 'cekongkir_all_destinations' );
foreach ( $locations as $location ) {
if (
stripos( $location['city'], $city ) !== false ||
stripos( $location['district'], $city ) !== false
) {
return [
'id' => $location['id'],
'label' => $location['label'],
if ( ! $destinations ) {
// Fetch from Rajaongkir API or database
$destinations = woonoow_fetch_rajaongkir_destinations();
set_transient( 'cekongkir_all_destinations', $destinations, DAY_IN_SECONDS );
}
// Filter by search term
$results = array_filter( $destinations, function( $dest ) use ( $search ) {
return stripos( $dest['label'], $search ) !== false;
});
// Limit to 50 results
$results = array_slice( array_values( $results ), 0, 50 );
return new WP_REST_Response( $results, 200 );
}
/**
* 3. Fetch destinations from Rajaongkir database
*/
function woonoow_fetch_rajaongkir_destinations() {
global $wpdb;
$destinations = [];
// Try to get from Rajaongkir plugin's stored data
$table = $wpdb->prefix . 'cekongkir_destinations';
if ( $wpdb->get_var( "SHOW TABLES LIKE '$table'" ) === $table ) {
$rows = $wpdb->get_results( "SELECT * FROM $table", ARRAY_A );
foreach ( $rows as $row ) {
$destinations[] = [
'value' => $row['id'],
'label' => $row['province'] . ', ' . $row['city'] . ', ' . $row['subdistrict'],
'province' => $row['province'],
'city' => $row['city'],
'subdistrict' => $row['subdistrict'],
];
}
}
return null;
// Fallback: Use options storage
if ( empty( $destinations ) ) {
$stored = get_option( 'cekongkir_destinations', [] );
foreach ( $stored as $id => $label ) {
$destinations[] = [
'value' => $id,
'label' => $label,
];
}
}
return $destinations;
}
/**
* 4. Enqueue scripts for checkout
*/
add_action( 'wp_enqueue_scripts', function() {
if ( ! is_checkout() && ! is_wc_endpoint_url() ) {
return;
}
wp_enqueue_script(
'woonoow-rajaongkir-bridge',
plugin_dir_url( __FILE__ ) . 'rajaongkir-bridge.js',
[],
'1.0.0',
true
);
wp_localize_script( 'woonoow-rajaongkir-bridge', 'WNW_RAJAONGKIR', [
'apiUrl' => rest_url( 'woonoow/v1/rajaongkir/destinations' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
]);
});
```
### JavaScript: `rajaongkir-bridge.js`
This script adds a searchable destination selector to the checkout:
```javascript
/**
* Rajaongkir Destination Selector for WooNooW
*
* Adds a searchable dropdown to select Indonesian subdistrict
*/
(function() {
'use strict';
// Only run on checkout
if (!document.querySelector('.woonoow-checkout, .woocommerce-checkout')) {
return;
}
// Wait for DOM
document.addEventListener('DOMContentLoaded', function() {
initRajaongkirSelector();
});
function initRajaongkirSelector() {
// Find the city/state fields in shipping form
const countryField = document.querySelector('[name="shipping_country"], [name="billing_country"]');
if (!countryField) return;
// Check if Indonesia is selected
const checkCountry = () => {
const country = countryField.value;
if (country === 'ID') {
showDestinationSelector();
} else {
hideDestinationSelector();
}
};
countryField.addEventListener('change', checkCountry);
checkCountry();
}
function showDestinationSelector() {
// Create searchable destination field if not exists
if (document.querySelector('#rajaongkir-destination-wrapper')) {
document.querySelector('#rajaongkir-destination-wrapper').style.display = 'block';
return;
}
const wrapper = document.createElement('div');
wrapper.id = 'rajaongkir-destination-wrapper';
wrapper.className = 'form-group md:col-span-2';
wrapper.innerHTML = `
<label class="block text-sm font-medium mb-2">
Destination (Province, City, Subdistrict) *
</label>
<div class="relative">
<input
type="text"
id="rajaongkir-search"
placeholder="Search destination..."
class="w-full border rounded-lg px-4 py-2"
autocomplete="off"
/>
<input type="hidden" id="rajaongkir-destination-id" name="destination_id" />
<input type="hidden" id="rajaongkir-destination-label" name="destination_label" />
<div id="rajaongkir-results" class="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto hidden"></div>
</div>
`;
// Insert after postcode field
const postcodeField = document.querySelector('[name="shipping_postcode"], [name="billing_postcode"]');
if (postcodeField) {
postcodeField.closest('.form-group, div')?.after(wrapper);
}
setupSearchHandler();
}
function hideDestinationSelector() {
const wrapper = document.querySelector('#rajaongkir-destination-wrapper');
if (wrapper) {
wrapper.style.display = 'none';
}
}
let searchTimeout;
function setupSearchHandler() {
const searchInput = document.querySelector('#rajaongkir-search');
const resultsDiv = document.querySelector('#rajaongkir-results');
const destinationIdInput = document.querySelector('#rajaongkir-destination-id');
const destinationLabelInput = document.querySelector('#rajaongkir-destination-label');
searchInput.addEventListener('input', function(e) {
const query = e.target.value;
clearTimeout(searchTimeout);
if (query.length < 2) {
resultsDiv.classList.add('hidden');
return;
}
searchTimeout = setTimeout(() => {
searchDestinations(query);
}, 300);
});
async function searchDestinations(query) {
try {
const response = await fetch(
`${WNW_RAJAONGKIR.apiUrl}?search=${encodeURIComponent(query)}`,
{
headers: {
'X-WP-Nonce': WNW_RAJAONGKIR.nonce,
},
}
);
const destinations = await response.json();
if (destinations.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-gray-500">No destinations found</div>';
} else {
resultsDiv.innerHTML = destinations.map(dest => `
<div
class="p-3 hover:bg-gray-100 cursor-pointer border-b last:border-0"
data-value="${dest.value}"
data-label="${dest.label}"
>
${dest.label}
</div>
`).join('');
}
resultsDiv.classList.remove('hidden');
// Setup click handlers
resultsDiv.querySelectorAll('[data-value]').forEach(item => {
item.addEventListener('click', function() {
const value = this.dataset.value;
const label = this.dataset.label;
searchInput.value = label;
destinationIdInput.value = value;
destinationLabelInput.value = label;
resultsDiv.classList.add('hidden');
// Trigger shipping calculation
document.body.dispatchEvent(new Event('woonoow:address_changed'));
});
});
} catch (error) {
console.error('Rajaongkir search error:', error);
}
}
// Hide on click outside
document.addEventListener('click', function(e) {
if (!e.target.closest('#rajaongkir-destination-wrapper')) {
resultsDiv.classList.add('hidden');
}
});
}
})();
```
---
## Frontend Integration (Optional)
## React Component Version (for customer-spa)
To add a proper Rajaongkir destination selector to WooNooW's admin order form, you need to:
If you're extending the customer-spa directly, here's a React component:
### 1. Register Custom Address Fields
```tsx
// components/RajaongkirDestinationSelector.tsx
import React, { useState, useEffect } from 'react';
import { api } from '@/lib/api/client';
import { SearchableSelect } from '@/components/ui/searchable-select';
```php
add_filter( 'woonoow/checkout/address_fields', function( $fields ) {
// Only for Indonesia
if ( WC()->customer && WC()->customer->get_shipping_country() === 'ID' ) {
$fields['destination_id'] = [
'type' => 'hidden',
'required' => true,
];
$fields['destination_selector'] = [
'type' => 'custom',
'component' => 'RajaongkirDestinationSelector',
'label' => __( 'Destination', 'woonoow-rajaongkir' ),
'required' => true,
];
interface DestinationOption {
value: string;
label: string;
}
interface Props {
value?: string;
label?: string;
onChange: (id: string, label: string) => void;
}
export function RajaongkirDestinationSelector({ value, label, onChange }: Props) {
const [search, setSearch] = useState('');
const [options, setOptions] = useState<DestinationOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (search.length < 2) {
setOptions([]);
return;
}
return $fields;
} );
const timer = setTimeout(async () => {
setLoading(true);
try {
const data = await api.get<DestinationOption[]>('/rajaongkir/destinations', { search });
setOptions(data);
} catch (error) {
console.error('Failed to search destinations:', error);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [search]);
const handleSelect = (selectedValue: string) => {
const selected = options.find(o => o.value === selectedValue);
if (selected) {
onChange(selected.value, selected.label);
}
};
return (
<div>
<label className="block text-sm font-medium mb-2">
Destination (Province, City, Subdistrict) *
</label>
<SearchableSelect
options={options}
value={value || ''}
onChange={handleSelect}
placeholder={loading ? 'Searching...' : 'Search destination...'}
emptyLabel="Type to search destinations"
/>
{label && (
<p className="text-xs text-gray-500 mt-1">Selected: {label}</p>
)}
</div>
);
}
```
### 2. Enqueue JavaScript for Destination Search
Usage in Checkout:
```php
add_action( 'woonoow/admin/enqueue_scripts', function() {
wp_enqueue_script(
'woonoow-rajaongkir',
plugins_url( 'assets/js/woonoow-integration.js', __FILE__ ),
[ 'woonoow-admin' ],
'1.0.0',
true
);
wp_localize_script( 'woonoow-rajaongkir', 'WNW_RAJAONGKIR', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'rajaongkir_search' ),
] );
} );
```tsx
// In Checkout/index.tsx
import { RajaongkirDestinationSelector } from '@/components/RajaongkirDestinationSelector';
// In the shipping form, add:
{shippingData.country === 'ID' && (
<div className="md:col-span-2">
<RajaongkirDestinationSelector
value={shippingData.destination_id}
label={shippingData.destination_label}
onChange={(id, label) => setShippingData({
...shippingData,
destination_id: id,
destination_label: label,
})}
/>
</div>
)}
```
---
## Testing
1. Install and configure Rajaongkir plugin
2. Add the code snippet above
3. Go to WooNooW → Orders → Create New Order
4. Add a product
5. Set country to Indonesia
6. Fill in city/state (or use destination selector if implemented)
1. Install Rajaongkir plugin and configure API key
2. Add the bridge code above
3. Go to WooNooW Checkout
4. Set country to Indonesia
5. Type in the destination search field
6. Select a destination from dropdown
7. Click "Calculate Shipping"
8. Verify Rajaongkir rates appear
@@ -171,29 +471,21 @@ add_action( 'woonoow/admin/enqueue_scripts', function() {
## Troubleshooting
### Rates not appearing?
### Destinations not loading?
1. Check browser DevTools Network tab for `/shipping/calculate` response
2. Look for `debug` object in response:
```json
{
"methods": [...],
"debug": {
"packages_count": 1,
"cart_items_count": 1,
"address": { ... }
}
}
```
3. Verify Rajaongkir session is set:
```php
error_log( 'destination_id: ' . WC()->session->get( 'selected_destination_id' ) );
```
Check the REST API endpoint:
```
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=jakarta
```
### Session not persisting?
Make sure WooCommerce session is initialized before the hook runs. The hook fires during REST API calls where session may not be initialized by default.
Verify the hook is firing:
```php
add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
error_log( 'Shipping data: ' . print_r( $shipping, true ) );
}, 5, 2 );
```
---