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.
This commit is contained in:
@@ -1,96 +1,69 @@
|
|||||||
# Rajaongkir Integration with WooNooW
|
# 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:**
|
1. You add fields via the standard `woocommerce_checkout_fields` filter
|
||||||
```
|
2. WooNooW's `/checkout/fields` API returns these fields
|
||||||
Country → State → City → Postcode
|
3. Customer-spa renders them automatically (including `searchable_select` type)
|
||||||
```
|
4. Field data is included in checkout submission
|
||||||
|
|
||||||
**Rajaongkir Flow:**
|
|
||||||
```
|
|
||||||
Country (ID) → Destination ID (subdistrict level)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## WooNooW Integration Hook
|
## Code Snippet (Add to Code Snippets or WPCodebox)
|
||||||
|
|
||||||
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
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Name: WooNooW Rajaongkir Bridge
|
* Rajaongkir Destination Field for WooNooW
|
||||||
* Description: Adds searchable destination selector for Rajaongkir shipping
|
*
|
||||||
* Version: 1.0.0
|
* Add this snippet to Code Snippets plugin or WPCodebox.
|
||||||
* Requires Plugins: woonoow, cekongkir
|
* Works with WooNooW's dynamic checkout field system.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
// 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
|
||||||
* 1. Hook into WooNooW shipping calculation to set Rajaongkir session
|
$country = '';
|
||||||
*/
|
if (function_exists('WC') && WC()->customer) {
|
||||||
add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
|
$country = WC()->customer->get_shipping_country();
|
||||||
// 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 ($country !== 'ID' && $country !== '') {
|
||||||
if ( ! empty( $shipping['destination_id'] ) ) {
|
return $fields;
|
||||||
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
|
||||||
* 2. REST API: Search Rajaongkir destinations
|
$fields['shipping']['shipping_destination_id'] = [
|
||||||
*/
|
'type' => 'searchable_select',
|
||||||
add_action( 'rest_api_init', function() {
|
'label' => __('Destination (Province, City, Subdistrict)', 'woonoow'),
|
||||||
register_rest_route( 'woonoow/v1', '/rajaongkir/destinations', [
|
'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,
|
||||||
|
];
|
||||||
|
|
||||||
|
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',
|
'methods' => 'GET',
|
||||||
'callback' => 'woonoow_search_rajaongkir_destinations',
|
'callback' => 'woonoow_rajaongkir_search_destinations',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'args' => [
|
'args' => [
|
||||||
'search' => [
|
'search' => [
|
||||||
'required' => true,
|
'required' => false,
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
],
|
],
|
||||||
@@ -98,65 +71,59 @@ add_action( 'rest_api_init', function() {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
function woonoow_search_rajaongkir_destinations( $request ) {
|
function woonoow_rajaongkir_search_destinations($request) {
|
||||||
$search = $request->get_param( 'search' );
|
$search = $request->get_param('search') ?? '';
|
||||||
|
|
||||||
if ( strlen( $search ) < 2 ) {
|
if (strlen($search) < 2) {
|
||||||
return new WP_REST_Response( [], 200 );
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Rajaongkir destinations from transient/cache
|
// Get cached destinations
|
||||||
$destinations = get_transient( 'cekongkir_all_destinations' );
|
$destinations = get_transient('woonoow_rajaongkir_destinations');
|
||||||
|
|
||||||
if ( ! $destinations ) {
|
if (!$destinations) {
|
||||||
// Fetch from Rajaongkir API or database
|
$destinations = woonoow_rajaongkir_build_destinations();
|
||||||
$destinations = woonoow_fetch_rajaongkir_destinations();
|
set_transient('woonoow_rajaongkir_destinations', $destinations, DAY_IN_SECONDS);
|
||||||
set_transient( 'cekongkir_all_destinations', $destinations, DAY_IN_SECONDS );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search term
|
// Filter by search term
|
||||||
$results = array_filter( $destinations, function( $dest ) use ( $search ) {
|
$results = array_filter($destinations, function($d) use ($search) {
|
||||||
return stripos( $dest['label'], $search ) !== false;
|
return stripos($d['label'], $search) !== false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limit to 50 results
|
// Return max 50 results
|
||||||
$results = array_slice( array_values( $results ), 0, 50 );
|
return array_slice(array_values($results), 0, 50);
|
||||||
|
|
||||||
return new WP_REST_Response( $results, 200 );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function woonoow_rajaongkir_build_destinations() {
|
||||||
* 3. Fetch destinations from Rajaongkir database
|
|
||||||
*/
|
|
||||||
function woonoow_fetch_rajaongkir_destinations() {
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
$destinations = [];
|
$destinations = [];
|
||||||
|
|
||||||
// Try to get from Rajaongkir plugin's stored data
|
// Try to get from Rajaongkir plugin's database
|
||||||
$table = $wpdb->prefix . 'cekongkir_destinations';
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'cekongkir_subdistrict'; // Adjust table name
|
||||||
|
|
||||||
if ( $wpdb->get_var( "SHOW TABLES LIKE '$table'" ) === $table ) {
|
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
|
||||||
$rows = $wpdb->get_results( "SELECT * FROM $table", ARRAY_A );
|
$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[] = [
|
$destinations[] = [
|
||||||
'value' => $row['id'],
|
'value' => $row['id'],
|
||||||
'label' => $row['province'] . ', ' . $row['city'] . ', ' . $row['subdistrict'],
|
'label' => $row['province'] . ', ' . $row['city'] . ', ' . $row['subdistrict'],
|
||||||
'province' => $row['province'],
|
|
||||||
'city' => $row['city'],
|
|
||||||
'subdistrict' => $row['subdistrict'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Use options storage
|
// Fallback: Try options storage
|
||||||
if ( empty( $destinations ) ) {
|
if (empty($destinations)) {
|
||||||
$stored = get_option( 'cekongkir_destinations', [] );
|
$stored = get_option('cekongkir_subdistricts', []);
|
||||||
foreach ( $stored as $id => $label ) {
|
foreach ($stored as $id => $data) {
|
||||||
$destinations[] = [
|
$destinations[] = [
|
||||||
'value' => $id,
|
'value' => (string) $id,
|
||||||
'label' => $label,
|
'label' => is_array($data) ? ($data['label'] ?? $data['name'] ?? $id) : $data,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,333 +131,107 @@ function woonoow_fetch_rajaongkir_destinations() {
|
|||||||
return $destinations;
|
return $destinations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 3. Hook into shipping calculation to set Rajaongkir session
|
||||||
* 4. Enqueue scripts for checkout
|
add_action('woonoow/shipping/before_calculate', function($shipping, $items) {
|
||||||
*/
|
// Check for destination_id in shipping data
|
||||||
add_action( 'wp_enqueue_scripts', function() {
|
$destination_id = $shipping['destination_id'] ?? ($shipping['shipping_destination_id'] ?? '');
|
||||||
if ( ! is_checkout() && ! is_wc_endpoint_url() ) {
|
|
||||||
|
if (empty($destination_id)) {
|
||||||
|
WC()->session->__unset('selected_destination_id');
|
||||||
|
WC()->session->__unset('selected_destination_label');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wp_enqueue_script(
|
// Set Rajaongkir session variables
|
||||||
'woonoow-rajaongkir-bridge',
|
WC()->session->set('selected_destination_id', intval($destination_id));
|
||||||
plugin_dir_url( __FILE__ ) . 'rajaongkir-bridge.js',
|
WC()->session->set('selected_destination_label', sanitize_text_field(
|
||||||
[],
|
$shipping['destination_id_label'] ?? $shipping['shipping_destination_id_label'] ?? ''
|
||||||
'1.0.0',
|
));
|
||||||
true
|
}, 10, 2);
|
||||||
);
|
|
||||||
|
|
||||||
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)
|
## 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
|
| Option | Description |
|
||||||
// components/RajaongkirDestinationSelector.tsx
|
|--------|-------------|
|
||||||
import React, { useState, useEffect } from 'react';
|
| `type` | `'searchable_select'` for API-backed search |
|
||||||
import { api } from '@/lib/api/client';
|
| `search_endpoint` | REST API endpoint for searching |
|
||||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
| `search_param` | Query param name (default: 'search') |
|
||||||
|
| `min_chars` | Minimum characters before searching (default: 2) |
|
||||||
interface DestinationOption {
|
| `placeholder` | Input placeholder text |
|
||||||
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
|
## Testing
|
||||||
|
|
||||||
1. Install Rajaongkir plugin and configure API key
|
1. Add the code snippet via Code Snippets plugin
|
||||||
2. Add the bridge code above
|
2. Go to WooNooW checkout with a product in cart
|
||||||
3. Go to WooNooW Checkout
|
3. Set country to Indonesia
|
||||||
4. Set country to Indonesia
|
4. A "Destination" field should appear
|
||||||
5. Type in the destination search field
|
5. Type at least 2 characters to search
|
||||||
6. Select a destination from dropdown
|
6. Select a destination from dropdown
|
||||||
7. Click "Calculate Shipping"
|
7. Complete checkout - shipping rates should use selected destination
|
||||||
8. Verify Rajaongkir rates appear
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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
|
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=jakarta
|
||||||
```
|
```
|
||||||
|
|
||||||
### Session not persisting?
|
### Session not set?
|
||||||
|
|
||||||
Verify the hook is firing:
|
|
||||||
```php
|
```php
|
||||||
add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
|
add_action('woonoow/shipping/before_calculate', function($shipping) {
|
||||||
error_log( 'Shipping data: ' . print_r( $shipping, true ) );
|
error_log('Shipping data: ' . print_r($shipping, true));
|
||||||
}, 5, 2 );
|
}, 5);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping integration guide
|
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping patterns
|
||||||
- [SHIPPING_METHOD_TYPES.md](SHIPPING_METHOD_TYPES.md) - Shipping method types explanation
|
- [HOOKS_REGISTRY.md](HOOKS_REGISTRY.md) - All WooNooW hooks
|
||||||
- [Rajaongkir Plugin Documentation](https://cekongkir.com/docs)
|
|
||||||
|
|||||||
294
customer-spa/src/components/DynamicCheckoutField.tsx
Normal file
294
customer-spa/src/components/DynamicCheckoutField.tsx
Normal file
@@ -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<string, string> | null;
|
||||||
|
custom: boolean;
|
||||||
|
autocomplete?: string;
|
||||||
|
validate?: string[];
|
||||||
|
input_class?: string[];
|
||||||
|
custom_attributes?: Record<string, string>;
|
||||||
|
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<SearchOption[]>([]);
|
||||||
|
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<SearchOption[]>(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 (
|
||||||
|
<SearchableSelect
|
||||||
|
options={countryOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || 'Select country'}
|
||||||
|
disabled={countryOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'state':
|
||||||
|
return stateOptions.length > 0 ? (
|
||||||
|
<SearchableSelect
|
||||||
|
options={stateOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || 'Select state'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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 (
|
||||||
|
<SearchableSelect
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || `Select ${field.label}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'searchable_select':
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={searchOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
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 (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full border rounded-lg px-4 py-2 min-h-[100px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === '1' || value === 'true'}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
if (field.options) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(field.options).map(([val, label]) => (
|
||||||
|
<label key={val} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.key}
|
||||||
|
value={val}
|
||||||
|
checked={value === val}
|
||||||
|
onChange={() => onChange(val)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{String(label)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete || 'email'}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'tel':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete || 'tel'}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'password':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default: text input
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render label for checkbox (it's inline)
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CheckoutField };
|
||||||
@@ -4,6 +4,7 @@ import { useCartStore } from '@/lib/cart/store';
|
|||||||
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
|
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import SEOHead from '@/components/SEOHead';
|
import SEOHead from '@/components/SEOHead';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
@@ -154,6 +155,68 @@ export default function Checkout() {
|
|||||||
}
|
}
|
||||||
}, [shippingData.country, states]);
|
}, [shippingData.country, states]);
|
||||||
|
|
||||||
|
// Dynamic checkout fields from API
|
||||||
|
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
|
||||||
|
const [customFieldData, setCustomFieldData] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Fetch checkout fields from API
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCheckoutFields = async () => {
|
||||||
|
if (cart.items.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = cart.items.map(item => ({
|
||||||
|
product_id: item.product_id,
|
||||||
|
qty: item.quantity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const data = await api.post<{
|
||||||
|
ok: boolean;
|
||||||
|
fields: CheckoutField[];
|
||||||
|
is_digital_only: boolean;
|
||||||
|
}>('/checkout/fields', { items, is_digital_only: isVirtualOnly });
|
||||||
|
|
||||||
|
if (data.ok && data.fields) {
|
||||||
|
setCheckoutFields(data.fields);
|
||||||
|
|
||||||
|
// Initialize custom field values with defaults
|
||||||
|
const customDefaults: Record<string, string> = {};
|
||||||
|
data.fields.forEach(field => {
|
||||||
|
if (field.custom && field.default) {
|
||||||
|
customDefaults[field.key] = field.default;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(customDefaults).length > 0) {
|
||||||
|
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load checkout fields:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCheckoutFields();
|
||||||
|
}, [cart.items, isVirtualOnly]);
|
||||||
|
|
||||||
|
// Filter custom fields by fieldset
|
||||||
|
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden);
|
||||||
|
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden);
|
||||||
|
|
||||||
|
// Handler for custom field changes
|
||||||
|
const handleCustomFieldChange = (key: string, value: string) => {
|
||||||
|
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for label events from searchable_select
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLabelEvent = (e: Event) => {
|
||||||
|
const { key, value } = (e as CustomEvent).detail;
|
||||||
|
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
document.addEventListener('woonoow:field_label', handleLabelEvent);
|
||||||
|
return () => document.removeEventListener('woonoow:field_label', handleLabelEvent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load saved addresses
|
// Load saved addresses
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAddresses = async () => {
|
const loadAddresses = async () => {
|
||||||
@@ -335,6 +398,10 @@ export default function Checkout() {
|
|||||||
state: billingData.state,
|
state: billingData.state,
|
||||||
postcode: billingData.postcode,
|
postcode: billingData.postcode,
|
||||||
country: billingData.country,
|
country: billingData.country,
|
||||||
|
// Include custom billing fields
|
||||||
|
...Object.fromEntries(
|
||||||
|
billingCustomFields.map(f => [f.key.replace('billing_', ''), customFieldData[f.key] || ''])
|
||||||
|
),
|
||||||
},
|
},
|
||||||
shipping: shipToDifferentAddress ? {
|
shipping: shipToDifferentAddress ? {
|
||||||
first_name: shippingData.firstName,
|
first_name: shippingData.firstName,
|
||||||
@@ -345,11 +412,17 @@ export default function Checkout() {
|
|||||||
postcode: shippingData.postcode,
|
postcode: shippingData.postcode,
|
||||||
country: shippingData.country,
|
country: shippingData.country,
|
||||||
ship_to_different: true,
|
ship_to_different: true,
|
||||||
|
// Include custom shipping fields
|
||||||
|
...Object.fromEntries(
|
||||||
|
shippingCustomFields.map(f => [f.key.replace('shipping_', ''), customFieldData[f.key] || ''])
|
||||||
|
),
|
||||||
} : {
|
} : {
|
||||||
ship_to_different: false,
|
ship_to_different: false,
|
||||||
},
|
},
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
customer_note: orderNotes,
|
customer_note: orderNotes,
|
||||||
|
// Include all custom field data for backend processing
|
||||||
|
custom_fields: customFieldData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Submit order
|
// Submit order
|
||||||
@@ -578,6 +651,18 @@ export default function Checkout() {
|
|||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom billing fields from plugins */}
|
||||||
|
{billingCustomFields.map(field => (
|
||||||
|
<DynamicCheckoutField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={customFieldData[field.key] || ''}
|
||||||
|
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||||
|
countryOptions={countryOptions}
|
||||||
|
stateOptions={billingStateOptions}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -739,6 +824,18 @@ export default function Checkout() {
|
|||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom shipping fields from plugins */}
|
||||||
|
{shippingCustomFields.map(field => (
|
||||||
|
<DynamicCheckoutField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={customFieldData[field.key] || ''}
|
||||||
|
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||||
|
countryOptions={countryOptions}
|
||||||
|
stateOptions={shippingStateOptions}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -475,6 +475,14 @@ class CheckoutController {
|
|||||||
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
|
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
|
||||||
'autocomplete'=> $field['autocomplete'] ?? '',
|
'autocomplete'=> $field['autocomplete'] ?? '',
|
||||||
'validate' => $field['validate'] ?? [],
|
'validate' => $field['validate'] ?? [],
|
||||||
|
// New fields for dynamic rendering
|
||||||
|
'input_class' => $field['input_class'] ?? [],
|
||||||
|
'custom_attributes' => $field['custom_attributes'] ?? [],
|
||||||
|
'default' => $field['default'] ?? '',
|
||||||
|
// For searchable_select type
|
||||||
|
'search_endpoint' => $field['search_endpoint'] ?? null,
|
||||||
|
'search_param' => $field['search_param'] ?? 'search',
|
||||||
|
'min_chars' => $field['min_chars'] ?? 2,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user