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:
Dwindi Ramadhana
2026-01-08 11:48:53 +07:00
parent 2939ebfe6b
commit 6694d9e0c4
4 changed files with 548 additions and 408 deletions

View File

@@ -1,162 +1,129 @@
# 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
<?php
/**
* Plugin Name: WooNooW Rajaongkir Bridge
* Description: Adds searchable destination selector for Rajaongkir shipping
* Version: 1.0.0
* Requires Plugins: woonoow, cekongkir
* Rajaongkir Destination Field for WooNooW
*
* Add this snippet to Code Snippets plugin or WPCodebox.
* Works with WooNooW's dynamic checkout field system.
*/
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;
// 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();
}
// 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'] ?? '' ) );
if ($country !== 'ID' && $country !== '') {
return $fields;
}
}, 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',
// 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,
];
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,
];
}
}
@@ -164,333 +131,107 @@ function woonoow_fetch_rajaongkir_destinations() {
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 = `
<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');
}
});
}
})();
// 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<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>
)}
```
| 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

View 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 };

View File

@@ -4,6 +4,7 @@ import { useCartStore } from '@/lib/cart/store';
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
import { Button } from '@/components/ui/button';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency';
@@ -154,6 +155,68 @@ export default function Checkout() {
}
}, [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
useEffect(() => {
const loadAddresses = async () => {
@@ -335,6 +398,10 @@ export default function Checkout() {
state: billingData.state,
postcode: billingData.postcode,
country: billingData.country,
// Include custom billing fields
...Object.fromEntries(
billingCustomFields.map(f => [f.key.replace('billing_', ''), customFieldData[f.key] || ''])
),
},
shipping: shipToDifferentAddress ? {
first_name: shippingData.firstName,
@@ -345,11 +412,17 @@ export default function Checkout() {
postcode: shippingData.postcode,
country: shippingData.country,
ship_to_different: true,
// Include custom shipping fields
...Object.fromEntries(
shippingCustomFields.map(f => [f.key.replace('shipping_', ''), customFieldData[f.key] || ''])
),
} : {
ship_to_different: false,
},
payment_method: paymentMethod,
customer_note: orderNotes,
// Include all custom field data for backend processing
custom_fields: customFieldData,
};
// Submit order
@@ -578,6 +651,18 @@ export default function Checkout() {
className="w-full border rounded-lg px-4 py-2"
/>
</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>
@@ -739,6 +824,18 @@ export default function Checkout() {
className="w-full border rounded-lg px-4 py-2"
/>
</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>
)}
</>

View File

@@ -475,6 +475,14 @@ class CheckoutController {
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
'autocomplete'=> $field['autocomplete'] ?? '',
'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,
];
}
}