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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user