Files
WooNooW/RAJAONGKIR_INTEGRATION.md
Dwindi Ramadhana 2939ebfe6b 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
2026-01-08 11:19:37 +07:00

497 lines
15 KiB
Markdown

# Rajaongkir Integration with WooNooW
This guide explains how to bridge the Rajaongkir shipping plugin with WooNooW's admin order form and checkout flow.
---
## The Challenge
Rajaongkir doesn't use standard WooCommerce address fields. Instead of using `city` and `state`, it requires a **destination ID** from its own Indonesian location database.
**Standard WooCommerce Flow:**
```
Country → State → City → Postcode
```
**Rajaongkir Flow:**
```
Country (ID) → Destination ID (subdistrict level)
```
---
## WooNooW Integration Hook
WooNooW provides a hook that fires **before** shipping calculation. This allows plugins like Rajaongkir to set session variables or prepare any data they need.
### Hook: `woonoow/shipping/before_calculate`
```php
do_action( 'woonoow/shipping/before_calculate', $shipping_data, $items );
```
**Parameters:**
- `$shipping_data` (array) - The shipping address from frontend:
- `country` - Country code (e.g., 'ID')
- `state` - State code
- `city` - City name
- `postcode` - Postal code
- `address_1` - Street address
- `destination_id` - Custom field for Rajaongkir (added via addon)
- + Any custom fields added by addons
- `$items` (array) - Cart items being shipped
---
## Complete Integration: Searchable Destination Selector
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
/**
* 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' ) {
WC()->session->__unset( 'selected_destination_id' );
WC()->session->__unset( 'selected_destination_label' );
return;
}
// Set destination from frontend
if ( ! empty( $shipping['destination_id'] ) ) {
WC()->session->set( 'selected_destination_id', intval( $shipping['destination_id'] ) );
WC()->session->set( 'selected_destination_label', sanitize_text_field( $shipping['destination_label'] ?? '' ) );
}
}, 10, 2 );
/**
* 2. REST API: Search Rajaongkir destinations
*/
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' );
if ( strlen( $search ) < 2 ) {
return new WP_REST_Response( [], 200 );
}
// Get Rajaongkir destinations from transient/cache
$destinations = get_transient( 'cekongkir_all_destinations' );
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'],
];
}
}
// 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');
}
});
}
})();
```
---
## React Component Version (for customer-spa)
If you're extending the customer-spa directly, here's a React component:
```tsx
// components/RajaongkirDestinationSelector.tsx
import React, { useState, useEffect } from 'react';
import { api } from '@/lib/api/client';
import { SearchableSelect } from '@/components/ui/searchable-select';
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;
}
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>
);
}
```
Usage in Checkout:
```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 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
---
## Troubleshooting
### Destinations not loading?
Check the REST API endpoint:
```
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=jakarta
```
### Session not persisting?
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 );
```
---
## Related Documentation
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping integration guide
- [SHIPPING_METHOD_TYPES.md](SHIPPING_METHOD_TYPES.md) - Shipping method types explanation
- [Rajaongkir Plugin Documentation](https://cekongkir.com/docs)