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

15 KiB

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

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
/**
 * 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:

/**
 * 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:

// 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:

// 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:

add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
    error_log( 'Shipping data: ' . print_r( $shipping, true ) );
}, 5, 2 );