diff --git a/RAJAONGKIR_INTEGRATION.md b/RAJAONGKIR_INTEGRATION.md
index 2e34328..8241cf9 100644
--- a/RAJAONGKIR_INTEGRATION.md
+++ b/RAJAONGKIR_INTEGRATION.md
@@ -1,496 +1,237 @@
# Rajaongkir Integration with WooNooW
-This guide explains how to bridge the Rajaongkir shipping plugin with WooNooW's admin order form and checkout flow.
+This guide explains how to add Rajaongkir's destination selector to WooNooW checkout using a **simple code snippet** (no bridge plugin required).
---
-## The Challenge
+## How It Works
-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.
+WooNooW now supports **dynamic checkout fields**:
-**Standard WooCommerce Flow:**
-```
-Country → State → City → Postcode
-```
-
-**Rajaongkir Flow:**
-```
-Country (ID) → Destination ID (subdistrict level)
-```
+1. You add fields via the standard `woocommerce_checkout_fields` filter
+2. WooNooW's `/checkout/fields` API returns these fields
+3. Customer-spa renders them automatically (including `searchable_select` type)
+4. Field data is included in checkout submission
---
-## 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:
+## Code Snippet (Add to Code Snippets or WPCodebox)
```php
session->__unset( 'selected_destination_id' );
- WC()->session->__unset( 'selected_destination_label' );
- return;
+// 1. Add destination_id field to shipping checkout fields
+add_filter('woocommerce_checkout_fields', function($fields) {
+ // Only add for Indonesia or when country not yet selected
+ $country = '';
+ if (function_exists('WC') && WC()->customer) {
+ $country = WC()->customer->get_shipping_country();
+ }
+
+ if ($country !== 'ID' && $country !== '') {
+ return $fields;
}
- // 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 );
+ // Add searchable destination field
+ $fields['shipping']['shipping_destination_id'] = [
+ 'type' => 'searchable_select',
+ 'label' => __('Destination (Province, City, Subdistrict)', 'woonoow'),
+ 'required' => true,
+ 'priority' => 85, // After country/state, before postcode
+ 'class' => ['form-row-wide'],
+ 'placeholder' => __('Search destination...', 'woonoow'),
+ // WooNooW-specific: API endpoint for search
+ 'search_endpoint' => '/woonoow/v1/rajaongkir/destinations',
+ 'search_param' => 'search',
+ 'min_chars' => 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',
+ return $fields;
+});
+
+// 2. Register REST API endpoint for destination search
+add_action('rest_api_init', function() {
+ register_rest_route('woonoow/v1', '/rajaongkir/destinations', [
+ 'methods' => 'GET',
+ 'callback' => 'woonoow_rajaongkir_search_destinations',
'permission_callback' => '__return_true',
- 'args' => [
+ 'args' => [
'search' => [
- 'required' => true,
- 'type' => 'string',
+ 'required' => false,
+ 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
-function woonoow_search_rajaongkir_destinations( $request ) {
- $search = $request->get_param( 'search' );
+function woonoow_rajaongkir_search_destinations($request) {
+ $search = $request->get_param('search') ?? '';
- if ( strlen( $search ) < 2 ) {
- return new WP_REST_Response( [], 200 );
+ if (strlen($search) < 2) {
+ return [];
}
- // Get Rajaongkir destinations from transient/cache
- $destinations = get_transient( 'cekongkir_all_destinations' );
+ // Get cached destinations
+ $destinations = get_transient('woonoow_rajaongkir_destinations');
- if ( ! $destinations ) {
- // Fetch from Rajaongkir API or database
- $destinations = woonoow_fetch_rajaongkir_destinations();
- set_transient( 'cekongkir_all_destinations', $destinations, DAY_IN_SECONDS );
+ if (!$destinations) {
+ $destinations = woonoow_rajaongkir_build_destinations();
+ set_transient('woonoow_rajaongkir_destinations', $destinations, DAY_IN_SECONDS);
}
// Filter by search term
- $results = array_filter( $destinations, function( $dest ) use ( $search ) {
- return stripos( $dest['label'], $search ) !== false;
+ $results = array_filter($destinations, function($d) use ($search) {
+ return stripos($d['label'], $search) !== false;
});
- // Limit to 50 results
- $results = array_slice( array_values( $results ), 0, 50 );
-
- return new WP_REST_Response( $results, 200 );
+ // Return max 50 results
+ return array_slice(array_values($results), 0, 50);
}
-/**
- * 3. Fetch destinations from Rajaongkir database
- */
-function woonoow_fetch_rajaongkir_destinations() {
- global $wpdb;
-
+function woonoow_rajaongkir_build_destinations() {
$destinations = [];
- // Try to get from Rajaongkir plugin's stored data
- $table = $wpdb->prefix . 'cekongkir_destinations';
+ // Try to get from Rajaongkir plugin's database
+ global $wpdb;
+ $table = $wpdb->prefix . 'cekongkir_subdistrict'; // Adjust table name
- if ( $wpdb->get_var( "SHOW TABLES LIKE '$table'" ) === $table ) {
- $rows = $wpdb->get_results( "SELECT * FROM $table", ARRAY_A );
+ if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
+ $rows = $wpdb->get_results("
+ SELECT id, province, city, subdistrict
+ FROM $table
+ ORDER BY province, city, subdistrict
+ ", ARRAY_A);
- foreach ( $rows as $row ) {
+ 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 ) {
+ // Fallback: Try options storage
+ if (empty($destinations)) {
+ $stored = get_option('cekongkir_subdistricts', []);
+ foreach ($stored as $id => $data) {
$destinations[] = [
- 'value' => $id,
- 'label' => $label,
+ 'value' => (string) $id,
+ 'label' => is_array($data) ? ($data['label'] ?? $data['name'] ?? $id) : $data,
];
}
}
-
+
return $destinations;
}
-/**
- * 4. Enqueue scripts for checkout
- */
-add_action( 'wp_enqueue_scripts', function() {
- if ( ! is_checkout() && ! is_wc_endpoint_url() ) {
+// 3. Hook into shipping calculation to set Rajaongkir session
+add_action('woonoow/shipping/before_calculate', function($shipping, $items) {
+ // Check for destination_id in shipping data
+ $destination_id = $shipping['destination_id'] ?? ($shipping['shipping_destination_id'] ?? '');
+
+ if (empty($destination_id)) {
+ WC()->session->__unset('selected_destination_id');
+ WC()->session->__unset('selected_destination_label');
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 = `
-
-
- `;
-
- // 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 = 'No destinations found
';
- } else {
- resultsDiv.innerHTML = destinations.map(dest => `
-
- ${dest.label}
-
- `).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');
- }
- });
- }
-})();
+ // Set Rajaongkir session variables
+ WC()->session->set('selected_destination_id', intval($destination_id));
+ WC()->session->set('selected_destination_label', sanitize_text_field(
+ $shipping['destination_id_label'] ?? $shipping['shipping_destination_id_label'] ?? ''
+ ));
+}, 10, 2);
```
---
-## React Component Version (for customer-spa)
+## Field Configuration Options
-If you're extending the customer-spa directly, here's a React component:
+WooNooW's `searchable_select` type supports these options:
-```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([]);
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- if (search.length < 2) {
- setOptions([]);
- return;
- }
-
- const timer = setTimeout(async () => {
- setLoading(true);
- try {
- const data = await api.get('/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 (
-
-
-
- {label && (
-
Selected: {label}
- )}
-
- );
-}
-```
-
-Usage in Checkout:
-
-```tsx
-// In Checkout/index.tsx
-import { RajaongkirDestinationSelector } from '@/components/RajaongkirDestinationSelector';
-
-// In the shipping form, add:
-{shippingData.country === 'ID' && (
-
- setShippingData({
- ...shippingData,
- destination_id: id,
- destination_label: label,
- })}
- />
-
-)}
-```
+| Option | Description |
+|--------|-------------|
+| `type` | `'searchable_select'` for API-backed search |
+| `search_endpoint` | REST API endpoint for searching |
+| `search_param` | Query param name (default: 'search') |
+| `min_chars` | Minimum characters before searching (default: 2) |
+| `placeholder` | Input placeholder text |
---
## 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
+1. Add the code snippet via Code Snippets plugin
+2. Go to WooNooW checkout with a product in cart
+3. Set country to Indonesia
+4. A "Destination" field should appear
+5. Type at least 2 characters to search
6. Select a destination from dropdown
-7. Click "Calculate Shipping"
-8. Verify Rajaongkir rates appear
+7. Complete checkout - shipping rates should use selected destination
+
+---
+
+## How WooNooW Processes This
+
+```
+┌─────────────────┐ ┌───────────────────┐ ┌─────────────────┐
+│ PHP Filter adds │ ──▶ │ /checkout/fields │ ──▶ │ Customer-spa │
+│ shipping_ │ │ API returns field │ │ renders field │
+│ destination_id │ │ with type/config │ │ (searchable) │
+└─────────────────┘ └───────────────────┘ └─────────────────┘
+ │
+ ▼
+┌─────────────────┐ ┌───────────────────┐ ┌─────────────────┐
+│ Rajaongkir uses │ ◀── │ Hook fires with │ ◀── │ User submits │
+│ session data │ │ shipping data │ │ checkout │
+└─────────────────┘ └───────────────────┘ └─────────────────┘
+```
+
+---
+
+## Compatibility
+
+This works with:
+- **Standard WooCommerce filters** - no WooNooW-specific code needed
+- **Checkout Field Editor Pro** - all its fields will render
+- **Custom code snippets** - just use the filter
+- **Other shipping plugins** - same hook pattern works
---
## Troubleshooting
-### Destinations not loading?
+### Field not appearing?
+```php
+// Debug: Check if field is being added
+add_action('wp_footer', function() {
+ $fields = WC()->checkout()->get_checkout_fields();
+ error_log('Checkout fields: ' . print_r(array_keys($fields['shipping'] ?? []), true));
+});
+```
-Check the REST API endpoint:
+### Search not working?
```
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=jakarta
```
-### Session not persisting?
-
-Verify the hook is firing:
+### Session not set?
```php
-add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
- error_log( 'Shipping data: ' . print_r( $shipping, true ) );
-}, 5, 2 );
+add_action('woonoow/shipping/before_calculate', function($shipping) {
+ error_log('Shipping data: ' . print_r($shipping, true));
+}, 5);
```
---
## 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)
+- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping patterns
+- [HOOKS_REGISTRY.md](HOOKS_REGISTRY.md) - All WooNooW hooks
diff --git a/customer-spa/src/components/DynamicCheckoutField.tsx b/customer-spa/src/components/DynamicCheckoutField.tsx
new file mode 100644
index 0000000..80106ee
--- /dev/null
+++ b/customer-spa/src/components/DynamicCheckoutField.tsx
@@ -0,0 +1,294 @@
+import React, { useState, useEffect } from 'react';
+import { SearchableSelect } from '@/components/ui/searchable-select';
+import { api } from '@/lib/api/client';
+
+interface CheckoutField {
+ key: string;
+ fieldset: 'billing' | 'shipping' | 'account' | 'order';
+ type: string;
+ label: string;
+ placeholder?: string;
+ required: boolean;
+ hidden: boolean;
+ class?: string[];
+ priority: number;
+ options?: Record | null;
+ custom: boolean;
+ autocomplete?: string;
+ validate?: string[];
+ input_class?: string[];
+ custom_attributes?: Record;
+ default?: string;
+ // For searchable_select type
+ search_endpoint?: string | null;
+ search_param?: string;
+ min_chars?: number;
+}
+
+interface DynamicCheckoutFieldProps {
+ field: CheckoutField;
+ value: string;
+ onChange: (value: string) => void;
+ countryOptions?: { value: string; label: string }[];
+ stateOptions?: { value: string; label: string }[];
+}
+
+interface SearchOption {
+ value: string;
+ label: string;
+}
+
+export function DynamicCheckoutField({
+ field,
+ value,
+ onChange,
+ countryOptions = [],
+ stateOptions = [],
+}: DynamicCheckoutFieldProps) {
+ const [searchOptions, setSearchOptions] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ // For searchable_select with API endpoint
+ useEffect(() => {
+ if (field.type !== 'searchable_select' || !field.search_endpoint) {
+ 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]);
+
+ // Handle API search for searchable_select
+ const handleApiSearch = async (searchTerm: string) => {
+ if (!field.search_endpoint) return;
+
+ const minChars = field.min_chars || 2;
+ if (searchTerm.length < minChars) {
+ setSearchOptions([]);
+ return;
+ }
+
+ setIsSearching(true);
+ try {
+ const param = field.search_param || 'search';
+ const results = await api.get(field.search_endpoint, { [param]: searchTerm });
+ setSearchOptions(Array.isArray(results) ? results : []);
+ } catch (error) {
+ console.error('Search failed:', error);
+ setSearchOptions([]);
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ // Don't render hidden fields
+ if (field.hidden) {
+ return null;
+ }
+
+ // Get field key without prefix (billing_, shipping_)
+ const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
+
+ // Determine CSS classes
+ const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
+ field.class?.includes('form-row-wide');
+ const wrapperClass = isWide ? 'md:col-span-2' : '';
+
+ // Render based on type
+ const renderInput = () => {
+ switch (field.type) {
+ case 'country':
+ return (
+
+ );
+
+ case 'state':
+ return stateOptions.length > 0 ? (
+
+ ) : (
+ onChange(e.target.value)}
+ placeholder={field.placeholder}
+ required={field.required}
+ autoComplete={field.autocomplete}
+ className="w-full border rounded-lg px-4 py-2"
+ />
+ );
+
+ case 'select':
+ if (field.options && Object.keys(field.options).length > 0) {
+ const options = Object.entries(field.options).map(([val, label]) => ({
+ value: val,
+ label: String(label),
+ }));
+ return (
+
+ );
+ }
+ return null;
+
+ case 'searchable_select':
+ return (
+ {
+ onChange(v);
+ // Also store label for display
+ const selected = searchOptions.find(o => o.value === v);
+ if (selected) {
+ // Store label in a hidden field with _label suffix
+ const event = new CustomEvent('woonoow:field_label', {
+ detail: { key: field.key + '_label', value: selected.label }
+ });
+ document.dispatchEvent(event);
+ }
+ }}
+ placeholder={isSearching ? 'Searching...' : (field.placeholder || `Search ${field.label}...`)}
+ emptyLabel={
+ isSearching
+ ? 'Searching...'
+ : `Type at least ${field.min_chars || 2} characters to search`
+ }
+ />
+ );
+
+ case 'textarea':
+ return (
+