From e05635f3580dc92037e24e7c26ec668791b90d59 Mon Sep 17 00:00:00 2001 From: dwindown Date: Mon, 10 Nov 2025 14:34:15 +0700 Subject: [PATCH] feat(orders): Dynamic shipping fields from checkout API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Complete Rewrite of Shipping Implementation ### Backend (Already Done): - ✅ `/checkout/fields` API endpoint - ✅ Respects addon hide/show logic - ✅ Handles digital-only products - ✅ Returns field metadata (type, required, hidden, options, etc.) ### Frontend (New Implementation): **Replaced hardcoded shipping fields with dynamic API-driven rendering** #### Changes in OrderForm.tsx: 1. **Query checkout fields API:** - Fetches fields based on cart items - Enabled only when items exist - Passes product IDs and quantities 2. **Dynamic state management:** - Removed individual useState for each field (sFirst, sLast, sAddr1, etc.) - Replaced with single `shippingData` object: `Record` - Cleaner, more flexible state management 3. **Dynamic field rendering:** - Filters fields by fieldset === 'shipping' and !hidden - Sorts by priority - Renders based on field.type: - `select` → Select with options - `country` → SearchableSelect - `textarea` → Textarea - default → Input (text/email/tel) - Respects required flag with visual indicator - Auto-detects wide fields (address_1, address_2) 4. **Form submission:** - Uses `shippingData` directly instead of individual fields - Cleaner payload construction ### Benefits: - ✅ Addons can add custom fields (e.g., subdistrict) - ✅ Fields show/hide based on addon logic - ✅ Required flags respected - ✅ Digital products hide shipping correctly - ✅ No hardcoding - fully extensible - ✅ Maintains existing UX ### Testing: - Test with physical products → shipping fields appear - Test with digital products → shipping hidden - Test with addons that add fields → custom fields render - Test form submission → data sent correctly --- .../src/routes/Orders/partials/OrderForm.tsx | 133 ++++++++++-------- 1 file changed, 71 insertions(+), 62 deletions(-) diff --git a/admin-spa/src/routes/Orders/partials/OrderForm.tsx b/admin-spa/src/routes/Orders/partials/OrderForm.tsx index e45bae9..8347c6f 100644 --- a/admin-spa/src/routes/Orders/partials/OrderForm.tsx +++ b/admin-spa/src/routes/Orders/partials/OrderForm.tsx @@ -133,15 +133,9 @@ export default function OrderForm({ const [bCountry, setBCountry] = React.useState(initial?.billing?.country || baseCountry); const [bState, setBState] = React.useState(initial?.billing?.state || ''); - // Shipping toggle + fields + // Shipping toggle + dynamic fields const [shipDiff, setShipDiff] = React.useState(Boolean(initial?.shipping && !isEmptyAddress(initial?.shipping))); - const [sFirst, setSFirst] = React.useState(initial?.shipping?.first_name || ''); - const [sLast, setSLast] = React.useState(initial?.shipping?.last_name || ''); - const [sAddr1, setSAddr1] = React.useState(initial?.shipping?.address_1 || ''); - const [sCity, setSCity] = React.useState(initial?.shipping?.city || ''); - const [sPost, setSPost] = React.useState(initial?.shipping?.postcode || ''); - const [sCountry, setSCountry] = React.useState(initial?.shipping?.country || bCountry); - const [sState, setSState] = React.useState(initial?.shipping?.state || ''); + const [shippingData, setShippingData] = React.useState>(initial?.shipping || {}); // If store sells to a single country, force-select it for billing & shipping React.useEffect(() => { @@ -177,6 +171,18 @@ export default function OrderForm({ const [validatedCoupons, setValidatedCoupons] = React.useState([]); const [couponValidating, setCouponValidating] = React.useState(false); + // Fetch dynamic checkout fields based on cart items + const { data: checkoutFields } = useQuery({ + queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))], + queryFn: async () => { + if (items.length === 0) return null; + return api.post('/checkout/fields', { + items: items.map(i => ({ product_id: i.product_id, qty: i.qty })), + }); + }, + enabled: items.length > 0, + }); + // --- Product search for Add Item --- const [searchQ, setSearchQ] = React.useState(''); const [customerSearchQ, setCustomerSearchQ] = React.useState(''); @@ -324,15 +330,7 @@ export default function OrderForm({ const payload: OrderPayload = { status, billing: billingData, - shipping: shipDiff && hasPhysicalProduct ? { - first_name: sFirst, - last_name: sLast, - address_1: sAddr1, - city: sCity, - state: sState, - postcode: sPost, - country: sCountry, - } : undefined, + shipping: shipDiff && hasPhysicalProduct ? shippingData : undefined, payment_method: paymentMethod || undefined, shipping_method: shippingMethod || undefined, customer_note: note || undefined, @@ -803,54 +801,65 @@ export default function OrderForm({ )} - {/* Shipping address */} - {hasPhysicalProduct && shipDiff && ( + {/* Shipping address - Dynamic Fields */} + {hasPhysicalProduct && shipDiff && checkoutFields?.fields && (

{__('Shipping address')}

-
- - setSFirst(e.target.value)} /> -
-
- - setSLast(e.target.value)} /> -
-
- - setSAddr1(e.target.value)} /> -
-
- - setSCity(e.target.value)} /> -
-
- - setSPost(e.target.value)} /> -
-
- - -
-
- - -
+ {checkoutFields.fields + .filter((f: any) => f.fieldset === 'shipping' && !f.hidden) + .sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0)) + .map((field: any) => { + const isWide = ['address_1', 'address_2'].includes(field.key.replace('shipping_', '')); + const fieldKey = field.key.replace('shipping_', ''); + + return ( +
+ + {field.type === 'select' && field.options ? ( + + ) : field.key === 'shipping_country' ? ( + setShippingData({...shippingData, country: v})} + placeholder={field.placeholder || __('Select country')} + disabled={oneCountryOnly} + /> + ) : field.type === 'textarea' ? ( +