feat(orders): Dynamic shipping fields from checkout API

## 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<string, any>`
   - 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
This commit is contained in:
dwindown
2025-11-10 14:34:15 +07:00
parent 0c357849f6
commit e05635f358

View File

@@ -133,15 +133,9 @@ export default function OrderForm({
const [bCountry, setBCountry] = React.useState(initial?.billing?.country || baseCountry); const [bCountry, setBCountry] = React.useState(initial?.billing?.country || baseCountry);
const [bState, setBState] = React.useState(initial?.billing?.state || ''); 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 [shipDiff, setShipDiff] = React.useState(Boolean(initial?.shipping && !isEmptyAddress(initial?.shipping)));
const [sFirst, setSFirst] = React.useState(initial?.shipping?.first_name || ''); const [shippingData, setShippingData] = React.useState<Record<string, any>>(initial?.shipping || {});
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 || '');
// If store sells to a single country, force-select it for billing & shipping // If store sells to a single country, force-select it for billing & shipping
React.useEffect(() => { React.useEffect(() => {
@@ -177,6 +171,18 @@ export default function OrderForm({
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]); const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
const [couponValidating, setCouponValidating] = React.useState(false); 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 --- // --- Product search for Add Item ---
const [searchQ, setSearchQ] = React.useState(''); const [searchQ, setSearchQ] = React.useState('');
const [customerSearchQ, setCustomerSearchQ] = React.useState(''); const [customerSearchQ, setCustomerSearchQ] = React.useState('');
@@ -324,15 +330,7 @@ export default function OrderForm({
const payload: OrderPayload = { const payload: OrderPayload = {
status, status,
billing: billingData, billing: billingData,
shipping: shipDiff && hasPhysicalProduct ? { shipping: shipDiff && hasPhysicalProduct ? shippingData : undefined,
first_name: sFirst,
last_name: sLast,
address_1: sAddr1,
city: sCity,
state: sState,
postcode: sPost,
country: sCountry,
} : undefined,
payment_method: paymentMethod || undefined, payment_method: paymentMethod || undefined,
shipping_method: shippingMethod || undefined, shipping_method: shippingMethod || undefined,
customer_note: note || undefined, customer_note: note || undefined,
@@ -803,54 +801,65 @@ export default function OrderForm({
</div> </div>
)} )}
{/* Shipping address */} {/* Shipping address - Dynamic Fields */}
{hasPhysicalProduct && shipDiff && ( {hasPhysicalProduct && shipDiff && checkoutFields?.fields && (
<div className="rounded border p-4 space-y-3 mt-4"> <div className="rounded border p-4 space-y-3 mt-4">
<h3 className="text-sm font-medium">{__('Shipping address')}</h3> <h3 className="text-sm font-medium">{__('Shipping address')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> {checkoutFields.fields
<Label>{__('First name')}</Label> .filter((f: any) => f.fieldset === 'shipping' && !f.hidden)
<Input className="rounded-md border px-3 py-2" value={sFirst} onChange={e=>setSFirst(e.target.value)} /> .sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
</div> .map((field: any) => {
<div> const isWide = ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
<Label>{__('Last name')}</Label> const fieldKey = field.key.replace('shipping_', '');
<Input className="rounded-md border px-3 py-2" value={sLast} onChange={e=>setSLast(e.target.value)} />
</div> return (
<div className="md:col-span-2"> <div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
<Label>{__('Address')}</Label> <Label>
<Input className="rounded-md border px-3 py-2" value={sAddr1} onChange={e=>setSAddr1(e.target.value)} /> {field.label}
</div> {field.required && <span className="text-destructive ml-1">*</span>}
<div> </Label>
<Label>{__('City')}</Label> {field.type === 'select' && field.options ? (
<Input className="rounded-md border px-3 py-2" value={sCity} onChange={e=>setSCity(e.target.value)} /> <Select
</div> value={shippingData[fieldKey] || ''}
<div> onValueChange={(v) => setShippingData({...shippingData, [fieldKey]: v})}
<Label>{__('Postcode')}</Label> >
<Input className="rounded-md border px-3 py-2" value={sPost} onChange={e=>setSPost(e.target.value)} /> <SelectTrigger className="w-full">
</div> <SelectValue placeholder={field.placeholder || field.label} />
<div> </SelectTrigger>
<Label>{__('Country')}</Label>
<SearchableSelect
options={countryOptions}
value={sCountry}
onChange={setSCountry}
placeholder={countries.length ? __('Select country') : __('No countries')}
disabled={oneCountryOnly}
/>
</div>
<div>
<Label>{__('State/Province')}</Label>
<Select value={sState} onValueChange={setSState}>
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
<SelectContent className="max-h-64"> <SelectContent className="max-h-64">
{sStateOptions.length ? sStateOptions.map(o => ( {Object.entries(field.options).map(([value, label]: [string, any]) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem> <SelectItem key={value} value={value}>{label}</SelectItem>
)) : ( ))}
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
) : field.key === 'shipping_country' ? (
<SearchableSelect
options={countryOptions}
value={shippingData.country || ''}
onChange={(v) => setShippingData({...shippingData, country: v})}
placeholder={field.placeholder || __('Select country')}
disabled={oneCountryOnly}
/>
) : field.type === 'textarea' ? (
<Textarea
value={shippingData[fieldKey] || ''}
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})}
placeholder={field.placeholder}
required={field.required}
/>
) : (
<Input
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
value={shippingData[fieldKey] || ''}
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})}
placeholder={field.placeholder}
required={field.required}
/>
)}
</div> </div>
);
})}
</div> </div>
</div> </div>
)} )}