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
497 lines
15 KiB
Markdown
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)
|