diff --git a/admin-spa/src/components/DynamicCheckoutField.tsx b/admin-spa/src/components/DynamicCheckoutField.tsx new file mode 100644 index 0000000..32d2f93 --- /dev/null +++ b/admin-spa/src/components/DynamicCheckoutField.tsx @@ -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 | null; + custom: boolean; + autocomplete?: string; + validate?: string[]; + input_class?: string[]; + custom_attributes?: Record; + 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([]); + 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(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 ( + + ); + + case 'state': + return stateOptions.length > 0 ? ( + + ) : ( + 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 ( + + ); + } + return null; + + case 'searchable_select': + return ( + { + 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 ( +