feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)
This commit is contained in:
270
admin-spa/src/components/DynamicCheckoutField.tsx
Normal file
270
admin-spa/src/components/DynamicCheckoutField.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { api } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface CheckoutField {
|
||||
key: string;
|
||||
fieldset: 'billing' | 'shipping' | 'account' | 'order';
|
||||
type: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
hidden: boolean;
|
||||
class?: string[];
|
||||
priority: number;
|
||||
options?: Record<string, string> | null;
|
||||
custom: boolean;
|
||||
autocomplete?: string;
|
||||
validate?: string[];
|
||||
input_class?: string[];
|
||||
custom_attributes?: Record<string, string>;
|
||||
default?: string;
|
||||
// For searchable_select type
|
||||
search_endpoint?: string | null;
|
||||
search_param?: string;
|
||||
min_chars?: number;
|
||||
}
|
||||
|
||||
interface DynamicCheckoutFieldProps {
|
||||
field: CheckoutField;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
countryOptions?: { value: string; label: string }[];
|
||||
stateOptions?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
interface SearchOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DynamicCheckoutField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
countryOptions = [],
|
||||
stateOptions = [],
|
||||
}: DynamicCheckoutFieldProps) {
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Handle API search for searchable_select
|
||||
const handleApiSearch = async (searchTerm: string) => {
|
||||
if (!field.search_endpoint) return;
|
||||
|
||||
const minChars = field.min_chars || 2;
|
||||
if (searchTerm.length < minChars) {
|
||||
setSearchOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const param = field.search_param || 'search';
|
||||
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
|
||||
setSearchOptions(Array.isArray(results) ? results : []);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchOptions([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render hidden fields
|
||||
if (field.hidden || field.type === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get field key without prefix (billing_, shipping_)
|
||||
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
|
||||
|
||||
// Determine CSS classes
|
||||
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
|
||||
field.class?.includes('form-row-wide');
|
||||
const wrapperClass = isWide ? 'md:col-span-2' : '';
|
||||
|
||||
// Render based on type
|
||||
const renderInput = () => {
|
||||
switch (field.type) {
|
||||
case 'country':
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || 'Select country'}
|
||||
disabled={countryOptions.length <= 1}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'state':
|
||||
return stateOptions.length > 0 ? (
|
||||
<SearchableSelect
|
||||
options={stateOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || 'Select state'}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
if (field.options && Object.keys(field.options).length > 0) {
|
||||
const options = Object.entries(field.options).map(([val, label]) => ({
|
||||
value: val,
|
||||
label: String(label),
|
||||
}));
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || `Select ${field.label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'searchable_select':
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={searchOptions}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
// Store label for display
|
||||
const selected = searchOptions.find(o => o.value === v);
|
||||
if (selected) {
|
||||
const event = new CustomEvent('woonoow:field_label', {
|
||||
detail: { key: field.key + '_label', value: selected.label }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}}
|
||||
onSearch={handleApiSearch}
|
||||
isSearching={isSearching}
|
||||
placeholder={field.placeholder || `Search ${field.label}...`}
|
||||
emptyLabel={
|
||||
isSearching
|
||||
? 'Searching...'
|
||||
: `Type at least ${field.min_chars || 2} characters to search`
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === '1' || value === 'true'}
|
||||
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
if (field.options) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(field.options).map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.key}
|
||||
value={val}
|
||||
checked={value === val}
|
||||
onChange={() => onChange(val)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{String(label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'email':
|
||||
return (
|
||||
<Input
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete || 'email'}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tel':
|
||||
return (
|
||||
<Input
|
||||
type="tel"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete || 'tel'}
|
||||
/>
|
||||
);
|
||||
|
||||
// Default: text input
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render label for checkbox (it's inline)
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store'; // This should ideally come from settings
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -33,7 +33,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
params.set('quantity', quantity.toString());
|
||||
}
|
||||
params.set('redirect', redirect);
|
||||
|
||||
|
||||
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -48,17 +48,17 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
}
|
||||
};
|
||||
|
||||
const LinkRow = ({
|
||||
label,
|
||||
link,
|
||||
description
|
||||
}: {
|
||||
label: string;
|
||||
link: string;
|
||||
const LinkRow = ({
|
||||
label,
|
||||
link,
|
||||
description
|
||||
}: {
|
||||
label: string;
|
||||
link: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const isCopied = copiedLink === link;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -137,13 +137,13 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
<div className="border-b pb-2">
|
||||
<h4 className="font-medium">Simple Product Links</h4>
|
||||
</div>
|
||||
|
||||
|
||||
<LinkRow
|
||||
label="Add to Cart"
|
||||
link={generateLink(undefined, 'cart')}
|
||||
description="Adds product to cart and shows cart page"
|
||||
/>
|
||||
|
||||
|
||||
<LinkRow
|
||||
label="Direct to Checkout"
|
||||
link={generateLink(undefined, 'checkout')}
|
||||
@@ -172,22 +172,22 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
(ID: {variation.id})
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
|
||||
<div className="p-4 pt-0 space-y-3 border-t">
|
||||
<LinkRow
|
||||
label="Add to Cart"
|
||||
link={generateLink(variation.id, 'cart')}
|
||||
/>
|
||||
|
||||
|
||||
<LinkRow
|
||||
label="Direct to Checkout"
|
||||
link={generateLink(variation.id, 'checkout')}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function GeneralTab({
|
||||
// Copy link state and helpers
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store';
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateSimpleLink = (redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
if (!productId) return '';
|
||||
|
||||
@@ -46,7 +46,7 @@ export function VariationsTab({
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store';
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
if (!productId) return '';
|
||||
|
||||
Reference in New Issue
Block a user