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

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