feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)
This commit is contained in:
@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
||||
|
||||
// --- Types ------------------------------------------------------------
|
||||
export type CountryOption = { code: string; name: string };
|
||||
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
|
||||
customer_note?: string;
|
||||
currency?: string;
|
||||
currency_symbol?: string;
|
||||
totals?: {
|
||||
total_items?: number;
|
||||
total_shipping?: number;
|
||||
total_tax?: number;
|
||||
total_discount?: number;
|
||||
total?: number;
|
||||
shipping?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrderPayload = {
|
||||
@@ -91,6 +100,7 @@ export type OrderPayload = {
|
||||
customer_note?: string;
|
||||
register_as_member?: boolean;
|
||||
coupons?: string[];
|
||||
custom_fields?: Record<string, string>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -189,6 +199,9 @@ export default function OrderForm({
|
||||
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
||||
const [couponValidating, setCouponValidating] = React.useState(false);
|
||||
|
||||
// Custom field values (for plugin fields like destination_id)
|
||||
const [customFieldData, setCustomFieldData] = React.useState<Record<string, string>>({});
|
||||
|
||||
// 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 }))],
|
||||
@@ -201,10 +214,46 @@ export default function OrderForm({
|
||||
enabled: items.length > 0,
|
||||
});
|
||||
|
||||
// Apply default values from API for hidden fields (like customer checkout does)
|
||||
React.useEffect(() => {
|
||||
if (!checkoutFields?.fields) return;
|
||||
|
||||
// Initialize custom field defaults
|
||||
const customDefaults: Record<string, string> = {};
|
||||
checkoutFields.fields.forEach((field: any) => {
|
||||
if (field.default && field.custom) {
|
||||
customDefaults[field.key] = field.default;
|
||||
}
|
||||
});
|
||||
if (Object.keys(customDefaults).length > 0) {
|
||||
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
|
||||
}
|
||||
|
||||
// Set billing country default for hidden fields (e.g., Indonesia-only stores)
|
||||
const billingCountryField = checkoutFields.fields.find((f: any) => f.key === 'billing_country');
|
||||
if ((billingCountryField?.type === 'hidden' || billingCountryField?.hidden) && billingCountryField.default && !bCountry) {
|
||||
setBCountry(billingCountryField.default);
|
||||
}
|
||||
|
||||
// Set shipping country default for hidden fields
|
||||
const shippingCountryField = checkoutFields.fields.find((f: any) => f.key === 'shipping_country');
|
||||
if ((shippingCountryField?.type === 'hidden' || shippingCountryField?.hidden) && shippingCountryField.default && !shippingData.country) {
|
||||
setShippingData(prev => ({ ...prev, country: shippingCountryField.default }));
|
||||
}
|
||||
}, [checkoutFields?.fields]);
|
||||
|
||||
// Get effective shipping address (use billing if not shipping to different address)
|
||||
const effectiveShippingAddress = React.useMemo(() => {
|
||||
// Get destination_id from custom fields (Rajaongkir)
|
||||
const destinationId = shipDiff
|
||||
? customFieldData['shipping_destination_id']
|
||||
: customFieldData['billing_destination_id'];
|
||||
|
||||
if (shipDiff) {
|
||||
return shippingData;
|
||||
return {
|
||||
...shippingData,
|
||||
destination_id: destinationId || undefined,
|
||||
};
|
||||
}
|
||||
// Use billing address
|
||||
return {
|
||||
@@ -214,22 +263,19 @@ export default function OrderForm({
|
||||
postcode: bPost,
|
||||
address_1: bAddr1,
|
||||
address_2: '',
|
||||
destination_id: destinationId || undefined,
|
||||
};
|
||||
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]);
|
||||
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1, customFieldData]);
|
||||
|
||||
// Check if shipping address is complete enough to calculate rates
|
||||
// Should match customer checkout: just needs country to fetch (destination_id can provide more specific rates)
|
||||
const isShippingAddressComplete = React.useMemo(() => {
|
||||
const addr = effectiveShippingAddress;
|
||||
// Need at minimum: country, state (if applicable), city
|
||||
if (!addr.country) return false;
|
||||
if (!addr.city) return false;
|
||||
// If country has states, require state
|
||||
const countryStates = states[addr.country];
|
||||
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [effectiveShippingAddress, states]);
|
||||
// Need at minimum: country OR destination_id
|
||||
// destination_id from Rajaongkir is sufficient to calculate shipping
|
||||
if (addr.destination_id) return true;
|
||||
return !!addr.country;
|
||||
}, [effectiveShippingAddress]);
|
||||
|
||||
// Debounce city input to avoid hitting backend on every keypress
|
||||
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
||||
@@ -244,7 +290,7 @@ export default function OrderForm({
|
||||
|
||||
// Calculate shipping rates dynamically
|
||||
const { data: shippingRates, isLoading: shippingLoading } = useQuery({
|
||||
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode],
|
||||
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode, effectiveShippingAddress.destination_id],
|
||||
queryFn: async () => {
|
||||
return api.post('/shipping/calculate', {
|
||||
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
||||
@@ -424,6 +470,43 @@ export default function OrderForm({
|
||||
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
|
||||
const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||
|
||||
// Helper to get billing field config from API - returns null if hidden
|
||||
const getBillingField = (key: string) => {
|
||||
if (!checkoutFields?.fields) return { label: '', required: false }; // fallback when API not loaded
|
||||
const field = checkoutFields.fields.find((f: any) => f.key === key);
|
||||
// Check both hidden flag and type === 'hidden'
|
||||
if (!field || field.hidden || field.type === 'hidden') return null;
|
||||
return field;
|
||||
};
|
||||
|
||||
// Helper to check if billing field should have full width
|
||||
const isBillingFieldWide = (key: string) => {
|
||||
const field = getBillingField(key);
|
||||
if (!field) return false;
|
||||
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
|
||||
return hasFormRowWide || ['billing_address_1', 'billing_address_2'].includes(key);
|
||||
};
|
||||
|
||||
// Derive custom fields from API (for plugin fields like destination_id)
|
||||
const billingCustomFields = React.useMemo(() => {
|
||||
if (!checkoutFields?.fields) return [];
|
||||
return checkoutFields.fields
|
||||
.filter((f: any) => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden')
|
||||
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
||||
}, [checkoutFields?.fields]);
|
||||
|
||||
const shippingCustomFields = React.useMemo(() => {
|
||||
if (!checkoutFields?.fields) return [];
|
||||
return checkoutFields.fields
|
||||
.filter((f: any) => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden')
|
||||
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
||||
}, [checkoutFields?.fields]);
|
||||
|
||||
// Helper to handle custom field changes
|
||||
const handleCustomFieldChange = (key: string, value: string) => {
|
||||
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -453,6 +536,7 @@ export default function OrderForm({
|
||||
customer_note: note || undefined,
|
||||
items: itemsEditable ? items : undefined,
|
||||
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
||||
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1006,138 +1090,229 @@ export default function OrderForm({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Billing address - only show full address for physical products */}
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||
{mode === 'create' && (
|
||||
<SearchableSelect
|
||||
options={customers.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: (
|
||||
<div className="leading-tight">
|
||||
<div className="font-medium">{c.name || c.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||
</div>
|
||||
),
|
||||
searchText: `${c.name} ${c.email}`,
|
||||
customer: c,
|
||||
}))}
|
||||
value={undefined}
|
||||
onChange={async (val: string) => {
|
||||
const customer = customers.find((c: any) => String(c.id) === val);
|
||||
if (!customer) return;
|
||||
{/* Billing address - only show when items are added (so checkout fields API is loaded) */}
|
||||
{items.length > 0 && (
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||
{mode === 'create' && (
|
||||
<SearchableSelect
|
||||
options={customers.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: (
|
||||
<div className="leading-tight">
|
||||
<div className="font-medium">{c.name || c.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||
</div>
|
||||
),
|
||||
searchText: `${c.name} ${c.email}`,
|
||||
customer: c,
|
||||
}))}
|
||||
value={undefined}
|
||||
onChange={async (val: string) => {
|
||||
const customer = customers.find((c: any) => String(c.id) === val);
|
||||
if (!customer) return;
|
||||
|
||||
// Fetch full customer data
|
||||
try {
|
||||
const data = await CustomersApi.searchByEmail(customer.email);
|
||||
if (data.found && data.billing) {
|
||||
// Always fill name, email, phone
|
||||
setBFirst(data.billing.first_name || data.first_name || '');
|
||||
setBLast(data.billing.last_name || data.last_name || '');
|
||||
setBEmail(data.email || '');
|
||||
setBPhone(data.billing.phone || '');
|
||||
// Fetch full customer data
|
||||
try {
|
||||
const data = await CustomersApi.searchByEmail(customer.email);
|
||||
if (data.found && data.billing) {
|
||||
// Always fill name, email, phone
|
||||
setBFirst(data.billing.first_name || data.first_name || '');
|
||||
setBLast(data.billing.last_name || data.last_name || '');
|
||||
setBEmail(data.email || '');
|
||||
setBPhone(data.billing.phone || '');
|
||||
|
||||
// Only fill address fields if cart has physical products
|
||||
if (hasPhysicalProduct) {
|
||||
setBAddr1(data.billing.address_1 || '');
|
||||
setBCity(data.billing.city || '');
|
||||
setBPost(data.billing.postcode || '');
|
||||
setBCountry(data.billing.country || bCountry);
|
||||
setBState(data.billing.state || '');
|
||||
// Only fill address fields if cart has physical products
|
||||
if (hasPhysicalProduct) {
|
||||
setBAddr1(data.billing.address_1 || '');
|
||||
setBCity(data.billing.city || '');
|
||||
setBPost(data.billing.postcode || '');
|
||||
setBCountry(data.billing.country || bCountry);
|
||||
setBState(data.billing.state || '');
|
||||
|
||||
// Autofill shipping if available
|
||||
if (data.shipping && data.shipping.address_1) {
|
||||
setShipDiff(true);
|
||||
setShippingData({
|
||||
first_name: data.shipping.first_name || '',
|
||||
last_name: data.shipping.last_name || '',
|
||||
address_1: data.shipping.address_1 || '',
|
||||
city: data.shipping.city || '',
|
||||
postcode: data.shipping.postcode || '',
|
||||
country: data.shipping.country || bCountry,
|
||||
state: data.shipping.state || '',
|
||||
});
|
||||
// Autofill shipping if available
|
||||
if (data.shipping && data.shipping.address_1) {
|
||||
setShipDiff(true);
|
||||
setShippingData({
|
||||
first_name: data.shipping.first_name || '',
|
||||
last_name: data.shipping.last_name || '',
|
||||
address_1: data.shipping.address_1 || '',
|
||||
city: data.shipping.city || '',
|
||||
postcode: data.shipping.postcode || '',
|
||||
country: data.shipping.country || bCountry,
|
||||
state: data.shipping.state || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark customer as selected
|
||||
setSelectedCustomerId(data.user_id);
|
||||
}
|
||||
|
||||
// Mark customer as selected
|
||||
setSelectedCustomerId(data.user_id);
|
||||
} catch (e) {
|
||||
console.error('Customer autofill error:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Customer autofill error:', e);
|
||||
}
|
||||
|
||||
setCustomerSearchQ('');
|
||||
}}
|
||||
onSearch={setCustomerSearchQ}
|
||||
placeholder={__('Search customer...')}
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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={bFirst} onChange={e => setBFirst(e.target.value)} />
|
||||
setCustomerSearchQ('');
|
||||
}}
|
||||
onSearch={setCustomerSearchQ}
|
||||
placeholder={__('Search customer...')}
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Last name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e => setBLast(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Email')}</Label>
|
||||
<Input
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
className="rounded-md border px-3 py-2 appearance-none"
|
||||
value={bEmail}
|
||||
onChange={e => setBEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Phone')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e => setBPhone(e.target.value)} />
|
||||
</div>
|
||||
{/* Only show full address fields for physical products */}
|
||||
{hasPhysicalProduct && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<Label>{__('Address')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e => setBAddr1(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('City')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e => setBCity(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Postcode')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e => setBPost(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Country')}</Label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={bCountry}
|
||||
onChange={setBCountry}
|
||||
placeholder={countries.length ? __('Select country') : __('No countries')}
|
||||
disabled={oneCountryOnly}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Dynamic billing fields - respects API visibility, labels, required status */}
|
||||
{getBillingField('billing_first_name') && (
|
||||
<div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_first_name')?.label || __('First name')}
|
||||
{getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bFirst}
|
||||
onChange={e => setBFirst(e.target.value)}
|
||||
required={getBillingField('billing_first_name')?.required}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('State/Province')}</Label>
|
||||
<SearchableSelect
|
||||
options={bStateOptions}
|
||||
value={bState}
|
||||
onChange={setBState}
|
||||
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
||||
disabled={!bStateOptions.length}
|
||||
)}
|
||||
{getBillingField('billing_last_name') && (
|
||||
<div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_last_name')?.label || __('Last name')}
|
||||
{getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bLast}
|
||||
onChange={e => setBLast(e.target.value)}
|
||||
required={getBillingField('billing_last_name')?.required}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
{getBillingField('billing_email') && (
|
||||
<div className={isBillingFieldWide('billing_email') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_email')?.label || __('Email')}
|
||||
{getBillingField('billing_email')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
className="rounded-md border px-3 py-2 appearance-none"
|
||||
value={bEmail}
|
||||
onChange={e => setBEmail(e.target.value)}
|
||||
required={getBillingField('billing_email')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_phone') && (
|
||||
<div className={isBillingFieldWide('billing_phone') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_phone')?.label || __('Phone')}
|
||||
{getBillingField('billing_phone')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bPhone}
|
||||
onChange={e => setBPhone(e.target.value)}
|
||||
required={getBillingField('billing_phone')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Address fields - only shown for physical products AND when not hidden by API */}
|
||||
{hasPhysicalProduct && (
|
||||
<>
|
||||
{getBillingField('billing_address_1') && (
|
||||
<div className="md:col-span-2">
|
||||
<Label>
|
||||
{getBillingField('billing_address_1')?.label || __('Address')}
|
||||
{getBillingField('billing_address_1')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bAddr1}
|
||||
onChange={e => setBAddr1(e.target.value)}
|
||||
required={getBillingField('billing_address_1')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_city') && (
|
||||
<div className={isBillingFieldWide('billing_city') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_city')?.label || __('City')}
|
||||
{getBillingField('billing_city')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bCity}
|
||||
onChange={e => setBCity(e.target.value)}
|
||||
required={getBillingField('billing_city')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_postcode') && (
|
||||
<div className={isBillingFieldWide('billing_postcode') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_postcode')?.label || __('Postcode')}
|
||||
{getBillingField('billing_postcode')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bPost}
|
||||
onChange={e => setBPost(e.target.value)}
|
||||
required={getBillingField('billing_postcode')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_country') && (
|
||||
<div className={isBillingFieldWide('billing_country') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_country')?.label || __('Country')}
|
||||
{getBillingField('billing_country')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={bCountry}
|
||||
onChange={setBCountry}
|
||||
placeholder={countries.length ? __('Select country') : __('No countries')}
|
||||
disabled={oneCountryOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_state') && (
|
||||
<div className={isBillingFieldWide('billing_state') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_state')?.label || __('State/Province')}
|
||||
{getBillingField('billing_state')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
options={bStateOptions}
|
||||
value={bState}
|
||||
onChange={setBState}
|
||||
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
||||
disabled={!bStateOptions.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Billing custom fields from plugins (e.g., destination_id from Rajaongkir) */}
|
||||
{hasPhysicalProduct && billingCustomFields.map((field: CheckoutField) => (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={bStateOptions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional: Only show address fields and shipping for physical products */}
|
||||
{!hasPhysicalProduct && (
|
||||
@@ -1162,7 +1337,7 @@ export default function OrderForm({
|
||||
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{checkoutFields.fields
|
||||
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden)
|
||||
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden && f.type !== 'hidden')
|
||||
.sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
|
||||
.map((field: any) => {
|
||||
// Check for full width: address fields or form-row-wide class from PHP
|
||||
@@ -1170,6 +1345,20 @@ export default function OrderForm({
|
||||
const isWide = hasFormRowWide || ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
|
||||
const fieldKey = field.key.replace('shipping_', '');
|
||||
|
||||
// For searchable_select, DynamicCheckoutField renders its own label wrapper
|
||||
if (field.type === 'searchable_select') {
|
||||
return (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
@@ -1208,8 +1397,20 @@ export default function OrderForm({
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={shippingData[fieldKey] || ''}
|
||||
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
||||
value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
|
||||
onChange={(e) => field.custom
|
||||
? handleCustomFieldChange(field.key, e.target.value)
|
||||
: setShippingData({ ...shippingData, [fieldKey]: e.target.value })
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
) : field.custom ? (
|
||||
// For other custom field types, store in customFieldData
|
||||
<Input
|
||||
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(e) => handleCustomFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user