From 6694d9e0c4cad2c4e544d871d503827b2ab0c121 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Thu, 8 Jan 2026 11:48:53 +0700 Subject: [PATCH] feat(checkout): dynamic checkout fields with PHP filter support Backend (CheckoutController): - Enhanced get_fields() API with custom_attributes, search_endpoint, search_param, min_chars, input_class, default - Supports new 'searchable_select' field type for API-backed search Customer SPA: - Created DynamicCheckoutField component for all field types - Checkout fetches fields from /checkout/fields API - Renders custom fields from PHP filters (billing + shipping) - searchable_select type with live API search - Custom field data included in checkout submission This enables: - Checkout Field Editor Pro compatibility - Rajaongkir destination_id via simple code snippet - Any plugin using woocommerce_checkout_fields filter Updated RAJAONGKIR_INTEGRATION.md with code snippet approach. --- RAJAONGKIR_INTEGRATION.md | 557 +++++------------- .../src/components/DynamicCheckoutField.tsx | 294 +++++++++ customer-spa/src/pages/Checkout/index.tsx | 97 +++ includes/Api/CheckoutController.php | 8 + 4 files changed, 548 insertions(+), 408 deletions(-) create mode 100644 customer-spa/src/components/DynamicCheckoutField.tsx 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 ( +