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:
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 { 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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user