From 3357fbfcf1fa052716c8abf927b02bf392e0a78d Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 10 Jan 2026 00:50:32 +0700 Subject: [PATCH] feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP) --- .../src/components/DynamicCheckoutField.tsx | 270 ++++++++++ .../src/routes/Orders/partials/OrderForm.tsx | 471 +++++++++++++----- .../Products/partials/DirectCartLinks.tsx | 36 +- .../Products/partials/tabs/GeneralTab.tsx | 2 +- .../Products/partials/tabs/VariationsTab.tsx | 2 +- customer-spa/src/App.tsx | 4 - .../src/components/DynamicCheckoutField.tsx | 12 +- .../src/components/ui/searchable-select.tsx | 2 +- customer-spa/src/hooks/useAddToCartFromUrl.ts | 27 +- customer-spa/src/pages/Account/Addresses.tsx | 394 +++++++++------ .../Account/components/AccountLayout.tsx | 6 +- customer-spa/src/pages/Checkout/index.tsx | 91 ++-- customer-spa/src/pages/Login/index.tsx | 3 +- includes/Admin/Assets.php | 21 +- includes/Admin/Menu.php | 9 +- includes/Admin/StandaloneAdmin.php | 19 +- includes/Api/OrdersController.php | 74 ++- includes/Frontend/AddressController.php | 80 ++- includes/Frontend/Assets.php | 63 ++- includes/Frontend/TemplateOverride.php | 196 +++++++- 20 files changed, 1317 insertions(+), 465 deletions(-) create mode 100644 admin-spa/src/components/DynamicCheckoutField.tsx 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 ( +