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:
@@ -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<Record<string, any>>(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<any[]>([]);
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping address */}
|
||||
{hasPhysicalProduct && shipDiff && (
|
||||
{/* Shipping address - Dynamic Fields */}
|
||||
{hasPhysicalProduct && shipDiff && checkoutFields?.fields && (
|
||||
<div className="rounded border p-4 space-y-3 mt-4">
|
||||
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{__('First name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={sFirst} onChange={e=>setSFirst(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Last name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={sLast} onChange={e=>setSLast(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>{__('Address')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={sAddr1} onChange={e=>setSAddr1(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('City')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={sCity} onChange={e=>setSCity(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Postcode')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={sPost} onChange={e=>setSPost(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{sStateOptions.length ? sStateOptions.map(o => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
)) : (
|
||||
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{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 (
|
||||
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === 'select' && field.options ? (
|
||||
<Select
|
||||
value={shippingData[fieldKey] || ''}
|
||||
onValueChange={(v) => setShippingData({...shippingData, [fieldKey]: v})}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || field.label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{Object.entries(field.options).map(([value, label]: [string, any]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user