feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)

This commit is contained in:
Dwindi Ramadhana
2026-01-10 00:50:32 +07:00
parent d3ec580ec8
commit 3357fbfcf1
20 changed files with 1317 additions and 465 deletions

View 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>
);
}

View File

@@ -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}
/>

View File

@@ -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')}

View File

@@ -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 '';

View File

@@ -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 '';