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,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',
'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() { add_action('rest_api_init', function() {
register_rest_route('woonoow/v1', '/rajaongkir/destinations', [ 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)

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 { 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>
)} )}
</> </>

View File

@@ -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,
]; ];
} }
} }