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

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