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 { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { SearchableSelect } from '@/components/ui/searchable-select'; import { SearchableSelect } from '@/components/ui/searchable-select';
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
// --- Types ------------------------------------------------------------ // --- Types ------------------------------------------------------------
export type CountryOption = { code: string; name: string }; export type CountryOption = { code: string; name: string };
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
customer_note?: string; customer_note?: string;
currency?: string; currency?: string;
currency_symbol?: string; currency_symbol?: string;
totals?: {
total_items?: number;
total_shipping?: number;
total_tax?: number;
total_discount?: number;
total?: number;
shipping?: number;
};
}; };
export type OrderPayload = { export type OrderPayload = {
@@ -91,6 +100,7 @@ export type OrderPayload = {
customer_note?: string; customer_note?: string;
register_as_member?: boolean; register_as_member?: boolean;
coupons?: string[]; coupons?: string[];
custom_fields?: Record<string, string>;
}; };
type Props = { type Props = {
@@ -189,6 +199,9 @@ export default function OrderForm({
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]); const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
const [couponValidating, setCouponValidating] = React.useState(false); 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 // Fetch dynamic checkout fields based on cart items
const { data: checkoutFields } = useQuery({ const { data: checkoutFields } = useQuery({
queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))], 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, 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) // Get effective shipping address (use billing if not shipping to different address)
const effectiveShippingAddress = React.useMemo(() => { const effectiveShippingAddress = React.useMemo(() => {
// Get destination_id from custom fields (Rajaongkir)
const destinationId = shipDiff
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
if (shipDiff) { if (shipDiff) {
return shippingData; return {
...shippingData,
destination_id: destinationId || undefined,
};
} }
// Use billing address // Use billing address
return { return {
@@ -214,22 +263,19 @@ export default function OrderForm({
postcode: bPost, postcode: bPost,
address_1: bAddr1, address_1: bAddr1,
address_2: '', 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 // 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 isShippingAddressComplete = React.useMemo(() => {
const addr = effectiveShippingAddress; const addr = effectiveShippingAddress;
// Need at minimum: country, state (if applicable), city // Need at minimum: country OR destination_id
if (!addr.country) return false; // destination_id from Rajaongkir is sufficient to calculate shipping
if (!addr.city) return false; if (addr.destination_id) return true;
// If country has states, require state return !!addr.country;
const countryStates = states[addr.country]; }, [effectiveShippingAddress]);
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
return false;
}
return true;
}, [effectiveShippingAddress, states]);
// Debounce city input to avoid hitting backend on every keypress // Debounce city input to avoid hitting backend on every keypress
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city); const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
@@ -244,7 +290,7 @@ export default function OrderForm({
// Calculate shipping rates dynamically // Calculate shipping rates dynamically
const { data: shippingRates, isLoading: shippingLoading } = useQuery({ 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 () => { queryFn: async () => {
return api.post('/shipping/calculate', { return api.post('/shipping/calculate', {
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })), 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 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 })); 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -453,6 +536,7 @@ export default function OrderForm({
customer_note: note || undefined, customer_note: note || undefined,
items: itemsEditable ? items : undefined, items: itemsEditable ? items : undefined,
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined, coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
}; };
try { try {
@@ -1006,138 +1090,229 @@ export default function OrderForm({
)} )}
</div> </div>
)} )}
{/* Billing address - only show full address for physical products */} {/* Billing address - only show when items are added (so checkout fields API is loaded) */}
<div className="rounded border p-4 space-y-3"> {items.length > 0 && (
<div className="flex items-center justify-between mb-3"> <div className="rounded border p-4 space-y-3">
<h3 className="text-sm font-medium">{__('Billing address')}</h3> <div className="flex items-center justify-between mb-3">
{mode === 'create' && ( <h3 className="text-sm font-medium">{__('Billing address')}</h3>
<SearchableSelect {mode === 'create' && (
options={customers.map((c: any) => ({ <SearchableSelect
value: String(c.id), options={customers.map((c: any) => ({
label: ( value: String(c.id),
<div className="leading-tight"> label: (
<div className="font-medium">{c.name || c.email}</div> <div className="leading-tight">
<div className="text-xs text-muted-foreground">{c.email}</div> <div className="font-medium">{c.name || c.email}</div>
</div> <div className="text-xs text-muted-foreground">{c.email}</div>
), </div>
searchText: `${c.name} ${c.email}`, ),
customer: c, searchText: `${c.name} ${c.email}`,
}))} customer: c,
value={undefined} }))}
onChange={async (val: string) => { value={undefined}
const customer = customers.find((c: any) => String(c.id) === val); onChange={async (val: string) => {
if (!customer) return; const customer = customers.find((c: any) => String(c.id) === val);
if (!customer) return;
// Fetch full customer data // Fetch full customer data
try { try {
const data = await CustomersApi.searchByEmail(customer.email); const data = await CustomersApi.searchByEmail(customer.email);
if (data.found && data.billing) { if (data.found && data.billing) {
// Always fill name, email, phone // Always fill name, email, phone
setBFirst(data.billing.first_name || data.first_name || ''); setBFirst(data.billing.first_name || data.first_name || '');
setBLast(data.billing.last_name || data.last_name || ''); setBLast(data.billing.last_name || data.last_name || '');
setBEmail(data.email || ''); setBEmail(data.email || '');
setBPhone(data.billing.phone || ''); setBPhone(data.billing.phone || '');
// Only fill address fields if cart has physical products // Only fill address fields if cart has physical products
if (hasPhysicalProduct) { if (hasPhysicalProduct) {
setBAddr1(data.billing.address_1 || ''); setBAddr1(data.billing.address_1 || '');
setBCity(data.billing.city || ''); setBCity(data.billing.city || '');
setBPost(data.billing.postcode || ''); setBPost(data.billing.postcode || '');
setBCountry(data.billing.country || bCountry); setBCountry(data.billing.country || bCountry);
setBState(data.billing.state || ''); setBState(data.billing.state || '');
// Autofill shipping if available // Autofill shipping if available
if (data.shipping && data.shipping.address_1) { if (data.shipping && data.shipping.address_1) {
setShipDiff(true); setShipDiff(true);
setShippingData({ setShippingData({
first_name: data.shipping.first_name || '', first_name: data.shipping.first_name || '',
last_name: data.shipping.last_name || '', last_name: data.shipping.last_name || '',
address_1: data.shipping.address_1 || '', address_1: data.shipping.address_1 || '',
city: data.shipping.city || '', city: data.shipping.city || '',
postcode: data.shipping.postcode || '', postcode: data.shipping.postcode || '',
country: data.shipping.country || bCountry, country: data.shipping.country || bCountry,
state: data.shipping.state || '', state: data.shipping.state || '',
}); });
}
} }
// Mark customer as selected
setSelectedCustomerId(data.user_id);
} }
} catch (e) {
// Mark customer as selected console.error('Customer autofill error:', e);
setSelectedCustomerId(data.user_id);
} }
} catch (e) {
console.error('Customer autofill error:', e);
}
setCustomerSearchQ(''); setCustomerSearchQ('');
}} }}
onSearch={setCustomerSearchQ} onSearch={setCustomerSearchQ}
placeholder={__('Search customer...')} placeholder={__('Search customer...')}
className="w-64" 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)} />
</div> </div>
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Label>{__('Last name')}</Label> {/* Dynamic billing fields - respects API visibility, labels, required status */}
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e => setBLast(e.target.value)} /> {getBillingField('billing_first_name') && (
</div> <div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
<div> <Label>
<Label>{__('Email')}</Label> {getBillingField('billing_first_name')?.label || __('First name')}
<Input {getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
inputMode="email" </Label>
autoComplete="email" <Input
className="rounded-md border px-3 py-2 appearance-none" className="rounded-md border px-3 py-2"
value={bEmail} value={bFirst}
onChange={e => setBEmail(e.target.value)} onChange={e => setBFirst(e.target.value)}
/> required={getBillingField('billing_first_name')?.required}
</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> </div>
<div> )}
<Label>{__('State/Province')}</Label> {getBillingField('billing_last_name') && (
<SearchableSelect <div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
options={bStateOptions} <Label>
value={bState} {getBillingField('billing_last_name')?.label || __('Last name')}
onChange={setBState} {getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
placeholder={bStateOptions.length ? __('Select state') : __('N/A')} </Label>
disabled={!bStateOptions.length} <Input
className="rounded-md border px-3 py-2"
value={bLast}
onChange={e => setBLast(e.target.value)}
required={getBillingField('billing_last_name')?.required}
/> />
</div> </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>
</div> )}
{/* Conditional: Only show address fields and shipping for physical products */} {/* Conditional: Only show address fields and shipping for physical products */}
{!hasPhysicalProduct && ( {!hasPhysicalProduct && (
@@ -1162,7 +1337,7 @@ export default function OrderForm({
<h3 className="text-sm font-medium">{__('Shipping address')}</h3> <h3 className="text-sm font-medium">{__('Shipping address')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{checkoutFields.fields {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)) .sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
.map((field: any) => { .map((field: any) => {
// Check for full width: address fields or form-row-wide class from PHP // 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 isWide = hasFormRowWide || ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
const fieldKey = 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 ( return (
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}> <div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
<Label> <Label>
@@ -1208,8 +1397,20 @@ export default function OrderForm({
/> />
) : field.type === 'textarea' ? ( ) : field.type === 'textarea' ? (
<Textarea <Textarea
value={shippingData[fieldKey] || ''} value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })} 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} placeholder={field.placeholder}
required={field.required} required={field.required}
/> />

View File

@@ -21,7 +21,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
const [copiedLink, setCopiedLink] = useState<string | null>(null); const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin; 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 generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
const params = new URLSearchParams(); const params = new URLSearchParams();

View File

@@ -95,7 +95,7 @@ export function GeneralTab({
// Copy link state and helpers // Copy link state and helpers
const [copiedLink, setCopiedLink] = useState<string | null>(null); const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin; 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') => { const generateSimpleLink = (redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return ''; if (!productId) return '';

View File

@@ -46,7 +46,7 @@ export function VariationsTab({
const [copiedLink, setCopiedLink] = useState<string | null>(null); const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin; 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') => { const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return ''; if (!productId) return '';

View File

@@ -59,16 +59,12 @@ const getAppearanceSettings = () => {
const getInitialRoute = () => { const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app'); const appEl = document.getElementById('woonoow-customer-app');
const initialRoute = appEl?.getAttribute('data-initial-route'); const initialRoute = appEl?.getAttribute('data-initial-route');
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
console.log('[WooNooW Customer] App element:', appEl);
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
return initialRoute || '/shop'; // Default to shop if not specified return initialRoute || '/shop'; // Default to shop if not specified
}; };
// Router wrapper component that uses hooks requiring Router context // Router wrapper component that uses hooks requiring Router context
function AppRoutes() { function AppRoutes() {
const initialRoute = getInitialRoute(); const initialRoute = getInitialRoute();
console.log('[WooNooW Customer] Using initial route:', initialRoute);
return ( return (
<BaseLayout> <BaseLayout>

View File

@@ -124,7 +124,7 @@ export function DynamicCheckoutField({
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
autoComplete={field.autocomplete} autoComplete={field.autocomplete}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
); );
@@ -180,7 +180,7 @@ export function DynamicCheckoutField({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
className="w-full border rounded-lg px-4 py-2 min-h-[100px]" className="w-full border !rounded-lg px-4 py-2 min-h-[100px]"
/> />
); );
@@ -228,7 +228,7 @@ export function DynamicCheckoutField({
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
autoComplete={field.autocomplete || 'email'} autoComplete={field.autocomplete || 'email'}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
); );
@@ -241,7 +241,7 @@ export function DynamicCheckoutField({
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
autoComplete={field.autocomplete || 'tel'} autoComplete={field.autocomplete || 'tel'}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
); );
@@ -253,7 +253,7 @@ export function DynamicCheckoutField({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
); );
@@ -267,7 +267,7 @@ export function DynamicCheckoutField({
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
autoComplete={field.autocomplete} autoComplete={field.autocomplete}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
); );
} }

View File

@@ -68,7 +68,7 @@ export function SearchableSelect({
type="button" type="button"
role="combobox" role="combobox"
className={cn( className={cn(
"w-full flex items-center justify-between border rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/20", "w-full flex items-center justify-between border !rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:border-gray-400",
disabled && "opacity-50 cursor-not-allowed", disabled && "opacity-50 cursor-not-allowed",
className className
)} )}

View File

@@ -37,19 +37,9 @@ export function useAddToCartFromUrl() {
// Skip if already processed // Skip if already processed
if (processedRef.current.has(requestKey)) { if (processedRef.current.has(requestKey)) {
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
return; return;
} }
console.log('[WooNooW] Add to cart from URL:', {
productId,
variationId,
quantity,
redirect,
fullUrl: window.location.href,
requestKey,
});
// Mark as processed // Mark as processed
processedRef.current.add(requestKey); processedRef.current.add(requestKey);
@@ -58,7 +48,6 @@ export function useAddToCartFromUrl() {
// Update cart store with fresh data from API // Update cart store with fresh data from API
if (cartData) { if (cartData) {
setCart(cartData); setCart(cartData);
console.log('[WooNooW] Cart updated with fresh data:', cartData);
} }
// Remove URL parameters after adding to cart // Remove URL parameters after adding to cart
@@ -68,7 +57,6 @@ export function useAddToCartFromUrl() {
// Navigate based on redirect parameter // Navigate based on redirect parameter
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart'; const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
if (!location.pathname.includes(targetPage)) { if (!location.pathname.includes(targetPage)) {
console.log(`[WooNooW] Navigating to ${targetPage}`);
navigate(targetPage); navigate(targetPage);
} }
}) })
@@ -98,8 +86,6 @@ async function addToCart(
body.variation_id = parseInt(variationId, 10); body.variation_id = parseInt(variationId, 10);
} }
console.log('[WooNooW] Adding to cart:', body);
const response = await fetch(`${apiRoot}/cart/add`, { const response = await fetch(`${apiRoot}/cart/add`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -116,7 +102,6 @@ async function addToCart(
} }
const data = await response.json(); const data = await response.json();
console.log('[WooNooW] Product added to cart:', data);
// API returns {message, cart_item_key, cart} on success // API returns {message, cart_item_key, cart} on success
if (data.cart_item_key && data.cart) { if (data.cart_item_key && data.cart) {

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react'; import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead'; import SEOHead from '@/components/SEOHead';
import { DynamicCheckoutField } from '@/components/DynamicCheckoutField';
interface Address { interface Address {
id: number; id: number;
@@ -20,6 +21,35 @@ interface Address {
email?: string; email?: string;
phone?: string; phone?: string;
is_default: boolean; is_default: boolean;
// Custom fields
[key: string]: any;
}
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;
search_endpoint?: string | null;
search_param?: string;
min_chars?: number;
}
interface CountryOption {
value: string;
label: string;
} }
export default function Addresses() { export default function Addresses() {
@@ -27,23 +57,94 @@ export default function Addresses() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null); const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const [formData, setFormData] = useState<Partial<Address>>({ const [formData, setFormData] = useState<Record<string, any>>({
label: '', label: '',
type: 'both', type: 'both',
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: 'ID',
email: '',
phone: '',
is_default: false, is_default: false,
}); });
// Checkout fields from API
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
const [countryOptions, setCountryOptions] = useState<CountryOption[]>([]);
const [stateOptions, setStateOptions] = useState<CountryOption[]>([]);
const [loadingFields, setLoadingFields] = useState(true);
// Fetch checkout fields and countries
useEffect(() => {
const loadFieldsAndCountries = async () => {
try {
// Fetch checkout fields (POST method required by API)
const fieldsResponse = await api.post<{ fields: CheckoutField[] }>('/checkout/fields', {});
setCheckoutFields(fieldsResponse.fields || []);
// Fetch countries
const countriesResponse = await api.get<{ countries: Record<string, string> }>('/countries');
if (countriesResponse.countries) {
const options = Object.entries(countriesResponse.countries).map(([code, name]) => ({
value: code,
label: String(name),
}));
setCountryOptions(options);
}
} catch (error) {
console.error('Failed to load checkout fields:', error);
} finally {
setLoadingFields(false);
}
};
loadFieldsAndCountries();
}, []);
// Listen for field label events from DynamicCheckoutField (searchable_select)
// This captures the human-readable label alongside the ID value
useEffect(() => {
const handleFieldLabel = (event: CustomEvent<{ key: string; value: string }>) => {
const { key, value } = event.detail;
setFormData(prev => ({
...prev,
[key]: value,
}));
};
document.addEventListener('woonoow:field_label', handleFieldLabel as EventListener);
return () => {
document.removeEventListener('woonoow:field_label', handleFieldLabel as EventListener);
};
}, []);
// Fetch states when country changes
useEffect(() => {
const country = formData.country || formData.billing_country || '';
if (!country) {
setStateOptions([]);
return;
}
const loadStates = async () => {
try {
const response = await api.get<{ states: Record<string, string> }>(`/countries/${country}/states`);
if (response.states) {
const options = Object.entries(response.states).map(([code, name]) => ({
value: code,
label: String(name),
}));
setStateOptions(options);
} else {
setStateOptions([]);
}
} catch {
setStateOptions([]);
}
};
loadStates();
}, [formData.country, formData.billing_country]);
// Filter billing fields - API already returns them sorted by priority
const billingFields = useMemo(() => {
return checkoutFields
.filter(f => f.fieldset === 'billing' && !f.hidden && f.type !== 'hidden');
}, [checkoutFields]);
useEffect(() => { useEffect(() => {
loadAddresses(); loadAddresses();
}, []); }, []);
@@ -76,40 +177,86 @@ export default function Addresses() {
} }
}; };
// Helper to get field value - handles both prefixed and non-prefixed keys
const getFieldValue = (key: string): string => {
// Try exact key first
if (formData[key] !== undefined) return String(formData[key] || '');
// Try without prefix
const unprefixed = key.replace(/^billing_/, '');
if (formData[unprefixed] !== undefined) return String(formData[unprefixed] || '');
return '';
};
// Helper to set field value - stores both prefixed and unprefixed for compatibility
const setFieldValue = (key: string, value: string) => {
const unprefixed = key.replace(/^billing_/, '');
setFormData(prev => ({
...prev,
[key]: value,
[unprefixed]: value,
}));
};
const handleAdd = () => { const handleAdd = () => {
setEditingAddress(null); setEditingAddress(null);
setFormData({ // Initialize with defaults from API fields
const defaults: Record<string, any> = {
label: '', label: '',
type: 'both', type: 'both',
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: 'ID',
email: '',
phone: '',
is_default: false, is_default: false,
};
billingFields.forEach(field => {
if (field.default) {
const unprefixed = field.key.replace(/^billing_/, '');
defaults[field.key] = field.default;
defaults[unprefixed] = field.default;
}
}); });
setFormData(defaults);
setShowModal(true); setShowModal(true);
}; };
const handleEdit = (address: Address) => { const handleEdit = (address: Address) => {
setEditingAddress(address); setEditingAddress(address);
setFormData(address); // Map address fields to formData with both prefixed and unprefixed keys
const data: Record<string, any> = { ...address };
// Add billing_ prefixed versions
Object.entries(address).forEach(([key, value]) => {
data[`billing_${key}`] = value;
});
setFormData(data);
setShowModal(true); setShowModal(true);
}; };
const handleSave = async () => { const handleSave = async () => {
try { try {
// Prepare payload with unprefixed keys
const payload: Record<string, any> = {
label: formData.label,
type: formData.type,
is_default: formData.is_default,
};
// Add all fields (unprefixed)
billingFields.forEach(field => {
const unprefixed = field.key.replace(/^billing_/, '');
payload[unprefixed] = getFieldValue(field.key);
// Also include _label fields if they exist (for searchable_select fields)
const labelKey = field.key + '_label';
if (formData[labelKey]) {
const unprefixedLabel = unprefixed + '_label';
payload[unprefixedLabel] = formData[labelKey];
}
});
if (editingAddress) { if (editingAddress) {
await api.put(`/account/addresses/${editingAddress.id}`, formData); await api.put(`/account/addresses/${editingAddress.id}`, payload);
toast.success('Address updated successfully'); toast.success('Address updated successfully');
} else { } else {
await api.post('/account/addresses', formData); await api.post('/account/addresses', payload);
toast.success('Address added successfully'); toast.success('Address added successfully');
} }
setShowModal(false); setShowModal(false);
@@ -144,6 +291,13 @@ export default function Addresses() {
} }
}; };
// Check if a field should be wide (full width)
const isFieldWide = (field: CheckoutField): boolean => {
const fieldName = field.key.replace(/^billing_/, '');
return ['address_1', 'address_2', 'email'].includes(fieldName) ||
field.class?.includes('form-row-wide') || false;
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -245,161 +399,69 @@ export default function Addresses() {
{editingAddress ? 'Edit Address' : 'Add New Address'} {editingAddress ? 'Edit Address' : 'Add New Address'}
</h2> </h2>
<div className="space-y-4"> {loadingFields ? (
<div> <div className="py-8 text-center text-gray-500">Loading form fields...</div>
<label className="block text-sm font-medium mb-1">Label *</label> ) : (
<input <div className="space-y-4">
type="text" {/* Label field - always shown */}
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Home, Office, Parents"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Type *</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="both">Billing & Shipping</option>
<option value="billing">Billing Only</option>
<option value="shipping">Shipping Only</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">First Name *</label> <label className="block text-sm font-medium mb-1">Label *</label>
<input <input
type="text" type="text"
value={formData.first_name} value={formData.label || ''}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} onChange={(e) => setFormData({ ...formData, label: e.target.value })}
className="w-full px-3 py-2 border rounded-lg" placeholder="e.g., Home, Office, Parents"
className="w-full px-3 py-2 border !rounded-lg"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">Last Name *</label>
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div> {/* Address Type - always shown */}
<label className="block text-sm font-medium mb-1">Company</label>
<input
type="text"
value={formData.company}
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Line 1 *</label>
<input
type="text"
value={formData.address_1}
onChange={(e) => setFormData({ ...formData, address_1: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Line 2</label>
<input
type="text"
value={formData.address_2}
onChange={(e) => setFormData({ ...formData, address_2: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">City *</label> <label className="block text-sm font-medium mb-1">Address Type *</label>
<input <select
type="text" value={formData.type || 'both'}
value={formData.city} onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-3 py-2 border rounded-lg" className="w-full px-3 py-2 border rounded-lg"
/> >
<option value="both">Billing & Shipping</option>
<option value="billing">Billing Only</option>
<option value="shipping">Shipping Only</option>
</select>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">State/Province *</label> {/* Dynamic fields from checkout API - DynamicCheckoutField renders its own labels */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{billingFields.map((field) => (
<DynamicCheckoutField
key={field.key}
field={field}
value={getFieldValue(field.key)}
onChange={(v) => setFieldValue(field.key, v)}
countryOptions={countryOptions}
stateOptions={stateOptions}
/>
))}
</div>
{/* Set as default checkbox */}
<div className="flex items-center gap-2 pt-2">
<input <input
type="text" type="checkbox"
value={formData.state} id="is_default"
onChange={(e) => setFormData({ ...formData, state: e.target.value })} checked={formData.is_default || false}
className="w-full px-3 py-2 border rounded-lg" onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/> />
<label htmlFor="is_default" className="text-sm">Set as default address</label>
</div> </div>
</div> </div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Postcode *</label>
<input
type="text"
value={formData.postcode}
onChange={(e) => setFormData({ ...formData, postcode: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Country *</label>
<input
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_default"
checked={formData.is_default}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="is_default" className="text-sm">Set as default address</label>
</div>
</div>
<div className="flex gap-3 mt-6"> <div className="flex gap-3 mt-6">
<button <button
onClick={handleSave} onClick={handleSave}
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors" disabled={loadingFields}
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
Save Address Save Address
</button> </button>

View File

@@ -80,10 +80,12 @@ export function AccountLayout({ children }: AccountLayoutProps) {
}); });
// Full page reload to clear cookies and refresh state // Full page reload to clear cookies and refresh state
window.location.href = window.location.origin + '/store/'; const basePath = (window as any).woonoowCustomer?.basePath || '/store';
window.location.href = window.location.origin + basePath + '/';
} catch (error) { } catch (error) {
// Even on error, try to redirect and let server handle session // Even on error, try to redirect and let server handle session
window.location.href = window.location.origin + '/store/'; const basePath = (window as any).woonoowCustomer?.basePath || '/store';
window.location.href = window.location.origin + basePath + '/';
} }
}; };

View File

@@ -712,7 +712,7 @@ export default function Checkout() {
required={getBillingField('billing_first_name')?.required} required={getBillingField('billing_first_name')?.required}
value={billingData.firstName} value={billingData.firstName}
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })} onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -724,7 +724,7 @@ export default function Checkout() {
required={getBillingField('billing_last_name')?.required} required={getBillingField('billing_last_name')?.required}
value={billingData.lastName} value={billingData.lastName}
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })} onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -736,7 +736,7 @@ export default function Checkout() {
required={getBillingField('billing_email')?.required} required={getBillingField('billing_email')?.required}
value={billingData.email} value={billingData.email}
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })} onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -748,7 +748,7 @@ export default function Checkout() {
required={getBillingField('billing_phone')?.required} required={getBillingField('billing_phone')?.required}
value={billingData.phone} value={billingData.phone}
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })} onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -764,7 +764,7 @@ export default function Checkout() {
required={getBillingField('billing_address_1')?.required} required={getBillingField('billing_address_1')?.required}
value={billingData.address} value={billingData.address}
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })} onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -777,7 +777,7 @@ export default function Checkout() {
required={getBillingField('billing_city')?.required} required={getBillingField('billing_city')?.required}
value={billingData.city} value={billingData.city}
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })} onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -811,7 +811,7 @@ export default function Checkout() {
value={billingData.state} value={billingData.state}
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })} onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
placeholder="Enter state/province" placeholder="Enter state/province"
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
)} )}
</div> </div>
@@ -825,7 +825,7 @@ export default function Checkout() {
required={getBillingField('billing_postcode')?.required} required={getBillingField('billing_postcode')?.required}
value={billingData.postcode} value={billingData.postcode}
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })} onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -932,36 +932,43 @@ export default function Checkout() {
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */} {/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
{(!selectedShippingAddressId || showShippingForm) && ( {(!selectedShippingAddressId || showShippingForm) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> {/* Dynamic shipping fields using getShippingField like billing */}
<label className="block text-sm font-medium mb-2">First Name *</label> {getShippingField('shipping_first_name') && (
<input <div className={getFieldWrapperClass('shipping_first_name', 'shipping')}>
type="text" <label className="block text-sm font-medium mb-2">{getShippingField('shipping_first_name')?.label || 'First Name'} {getShippingField('shipping_first_name')?.required && '*'}</label>
required <input
value={shippingData.firstName} type="text"
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })} required={getShippingField('shipping_first_name')?.required}
className="w-full border rounded-lg px-4 py-2" value={shippingData.firstName}
/> onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
</div> className="w-full border !rounded-lg px-4 py-2"
<div> />
<label className="block text-sm font-medium mb-2">Last Name *</label> </div>
<input )}
type="text" {getShippingField('shipping_last_name') && (
required <div className={getFieldWrapperClass('shipping_last_name', 'shipping')}>
value={shippingData.lastName} <label className="block text-sm font-medium mb-2">{getShippingField('shipping_last_name')?.label || 'Last Name'} {getShippingField('shipping_last_name')?.required && '*'}</label>
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })} <input
className="w-full border rounded-lg px-4 py-2" type="text"
/> required={getShippingField('shipping_last_name')?.required}
</div> value={shippingData.lastName}
<div className="md:col-span-2"> onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
<label className="block text-sm font-medium mb-2">Street Address *</label> className="w-full border !rounded-lg px-4 py-2"
<input />
type="text" </div>
required )}
value={shippingData.address} {getShippingField('shipping_address_1') && (
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })} <div className={getFieldWrapperClass('shipping_address_1', 'shipping') || 'md:col-span-2'}>
className="w-full border rounded-lg px-4 py-2" <label className="block text-sm font-medium mb-2">{getShippingField('shipping_address_1')?.label || 'Street Address'} {getShippingField('shipping_address_1')?.required && '*'}</label>
/> <input
</div> type="text"
required={getShippingField('shipping_address_1')?.required}
value={shippingData.address}
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
className="w-full border !rounded-lg px-4 py-2"
/>
</div>
)}
{/* City field - conditionally rendered based on API */} {/* City field - conditionally rendered based on API */}
{getShippingField('shipping_city') && ( {getShippingField('shipping_city') && (
<div> <div>
@@ -971,7 +978,7 @@ export default function Checkout() {
required={getShippingField('shipping_city')?.required} required={getShippingField('shipping_city')?.required}
value={shippingData.city} value={shippingData.city}
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -1005,7 +1012,7 @@ export default function Checkout() {
value={shippingData.state} value={shippingData.state}
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
placeholder="Enter state/province" placeholder="Enter state/province"
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
)} )}
</div> </div>
@@ -1019,7 +1026,7 @@ export default function Checkout() {
required={getShippingField('shipping_postcode')?.required} required={getShippingField('shipping_postcode')?.required}
value={shippingData.postcode} value={shippingData.postcode}
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)} )}
@@ -1050,7 +1057,7 @@ export default function Checkout() {
value={orderNotes} value={orderNotes}
onChange={(e) => setOrderNotes(e.target.value)} onChange={(e) => setOrderNotes(e.target.value)}
placeholder="Notes about your order, e.g. special notes for delivery." placeholder="Notes about your order, e.g. special notes for delivery."
className="w-full border rounded-lg px-4 py-2 h-32" className="w-full border !rounded-lg px-4 py-2 h-32"
/> />
</div> </div>
)} )}

View File

@@ -100,7 +100,8 @@ export default function Login() {
// Set the target URL with hash route, then force reload // Set the target URL with hash route, then force reload
// The hash change alone doesn't reload the page, so cookies won't be refreshed // The hash change alone doesn't reload the page, so cookies won't be refreshed
const targetUrl = window.location.origin + '/store/#' + redirectTo; const basePath = (window as any).woonoowCustomer?.basePath || '/store';
const targetUrl = window.location.origin + basePath + '/#' + redirectTo;
window.location.href = targetUrl; window.location.href = targetUrl;
// Force page reload to refresh cookies and server-side state // Force page reload to refresh cookies and server-side state
window.location.reload(); window.location.reload();

View File

@@ -72,7 +72,7 @@ class Assets
'wpAdminUrl' => admin_url('admin.php?page=woonoow'), 'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(), 'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => home_url('/store/'), 'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false), 'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
]); ]);
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after'); wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
@@ -197,7 +197,7 @@ class Assets
'wpAdminUrl' => admin_url('admin.php?page=woonoow'), 'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(), 'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => home_url('/store/'), 'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false), 'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
]); ]);
@@ -312,4 +312,21 @@ class Assets
// Bump when releasing; in dev we don't cache-bust // Bump when releasing; in dev we don't cache-bust
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0'; return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
} }
/** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string
{
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if ($spa_page_id) {
$spa_url = get_permalink($spa_page_id);
if ($spa_url) {
return trailingslashit($spa_url);
}
}
// Fallback to /store/ if no SPA page configured
return home_url('/store/');
}
} }

View File

@@ -113,9 +113,14 @@ class Menu {
] ); ] );
// Add Store link if customer SPA is not disabled // Add Store link if customer SPA is not disabled
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = get_post($spa_page_id);
$customer_spa_enabled = get_option( 'woonoow_customer_spa_enabled', true ); $customer_spa_enabled = get_option( 'woonoow_customer_spa_enabled', true );
if ( $customer_spa_enabled ) { if ( $customer_spa_enabled && $spa_page) {
$store_url = home_url( '/store/' );
$spa_slug = $spa_page->post_name;
$store_url = home_url( '/' . $spa_slug );
$wp_admin_bar->add_node( [ $wp_admin_bar->add_node( [
'id' => 'woonoow-store', 'id' => 'woonoow-store',
'title' => '<span class="ab-icon dashicons-cart"></span><span class="ab-label">' . __( 'Store', 'woonoow' ) . '</span>', 'title' => '<span class="ab-icon dashicons-cart"></span><span class="ab-label">' . __( 'Store', 'woonoow' ) . '</span>',

View File

@@ -133,7 +133,7 @@ class StandaloneAdmin {
locale: <?php echo wp_json_encode( get_locale() ); ?>, locale: <?php echo wp_json_encode( get_locale() ); ?>,
siteUrl: <?php echo wp_json_encode( home_url() ); ?>, siteUrl: <?php echo wp_json_encode( home_url() ); ?>,
siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?>, siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?>,
storeUrl: <?php echo wp_json_encode( home_url( '/store/' ) ); ?>, storeUrl: <?php echo wp_json_encode( self::get_spa_url() ); ?>,
customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?> customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?>
}; };
@@ -196,4 +196,21 @@ class StandaloneAdmin {
'currency_pos' => (string) $currency_pos, 'currency_pos' => (string) $currency_pos,
]; ];
} }
/** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string
{
$appearance_settings = get_option( 'woonoow_appearance_settings', [] );
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if ( $spa_page_id ) {
$spa_url = get_permalink( $spa_page_id );
if ( $spa_url ) {
return trailingslashit( $spa_url );
}
}
// Fallback to /store/ if no SPA page configured
return home_url( '/store/' );
}
} }

View File

@@ -858,25 +858,49 @@ class OrdersController {
} }
} }
// 3. Billing information is required for a healthy order // 3. Billing information required based on checkout fields configuration
$required_billing_fields = [ // Get checkout field settings to respect hidden/required status from PHP snippets
'first_name' => __( 'Billing first name', 'woonoow' ), $checkout_fields = apply_filters( 'woonoow/checkout/fields', [], $items );
'last_name' => __( 'Billing last name', 'woonoow' ),
'email' => __( 'Billing email', 'woonoow' ),
];
// Address fields only required for physical products // Helper to check if a billing field is required
if ( $has_physical_product ) { $is_field_required = function( $field_key ) use ( $checkout_fields ) {
$required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' ); foreach ( $checkout_fields as $field ) {
$required_billing_fields['city'] = __( 'Billing city', 'woonoow' ); if ( isset( $field['key'] ) && $field['key'] === $field_key ) {
$required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' ); // Field is not required if hidden or explicitly not required
$required_billing_fields['country'] = __( 'Billing country', 'woonoow' ); if ( ! empty( $field['hidden'] ) || $field['type'] === 'hidden' ) {
return false;
}
return ! empty( $field['required'] );
}
}
// Default: core fields are required if not found in API
return true;
};
// Core billing fields - check against API configuration
if ( $is_field_required( 'billing_first_name' ) && empty( $billing['first_name'] ) ) {
$validation_errors[] = __( 'Billing first name is required', 'woonoow' );
}
if ( $is_field_required( 'billing_last_name' ) && empty( $billing['last_name'] ) ) {
$validation_errors[] = __( 'Billing last name is required', 'woonoow' );
}
if ( $is_field_required( 'billing_email' ) && empty( $billing['email'] ) ) {
$validation_errors[] = __( 'Billing email is required', 'woonoow' );
} }
foreach ( $required_billing_fields as $field => $label ) { // Address fields only required for physical products AND if not hidden
if ( empty( $billing[ $field ] ) ) { if ( $has_physical_product ) {
/* translators: %s: field label */ if ( $is_field_required( 'billing_address_1' ) && empty( $billing['address_1'] ) ) {
$validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label ); $validation_errors[] = __( 'Billing address is required', 'woonoow' );
}
if ( $is_field_required( 'billing_city' ) && empty( $billing['city'] ) ) {
$validation_errors[] = __( 'Billing city is required', 'woonoow' );
}
if ( $is_field_required( 'billing_postcode' ) && empty( $billing['postcode'] ) ) {
$validation_errors[] = __( 'Billing postcode is required', 'woonoow' );
}
if ( $is_field_required( 'billing_country' ) && empty( $billing['country'] ) ) {
$validation_errors[] = __( 'Billing country is required', 'woonoow' );
} }
} }
@@ -1244,10 +1268,18 @@ class OrdersController {
$s = sanitize_text_field( $req->get_param('search') ?? '' ); $s = sanitize_text_field( $req->get_param('search') ?? '' );
$limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) ); $limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) );
$args = [ 'limit' => $limit, 'status' => 'publish' ]; // Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter)
if ( $s ) { $args['s'] = $s; } $args = [
'post_type' => [ 'product' ],
'post_status' => 'publish',
'posts_per_page' => $limit,
];
if ( $s ) {
$args['s'] = $s;
}
$prods = wc_get_products( $args ); $query = new \WP_Query( $args );
$prods = array_filter( array_map( 'wc_get_product', $query->posts ) );
$rows = array_map( function( $p ) { $rows = array_map( function( $p ) {
$data = [ $data = [
'id' => $p->get_id(), 'id' => $p->get_id(),

View File

@@ -86,25 +86,39 @@ class AddressController {
// Generate new ID // Generate new ID
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1; $new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
// Prepare address data // Standard address fields
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$reserved_fields = ['id', 'label', 'type', 'is_default'];
// Prepare address data with standard fields
$address = [ $address = [
'id' => $new_id, 'id' => $new_id,
'label' => sanitize_text_field($request->get_param('label')), 'label' => sanitize_text_field($request->get_param('label')),
'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both' 'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both'
'first_name' => sanitize_text_field($request->get_param('first_name')),
'last_name' => sanitize_text_field($request->get_param('last_name')),
'company' => sanitize_text_field($request->get_param('company')),
'address_1' => sanitize_text_field($request->get_param('address_1')),
'address_2' => sanitize_text_field($request->get_param('address_2')),
'city' => sanitize_text_field($request->get_param('city')),
'state' => sanitize_text_field($request->get_param('state')),
'postcode' => sanitize_text_field($request->get_param('postcode')),
'country' => sanitize_text_field($request->get_param('country')),
'email' => sanitize_email($request->get_param('email')),
'phone' => sanitize_text_field($request->get_param('phone')),
'is_default' => (bool) $request->get_param('is_default'), 'is_default' => (bool) $request->get_param('is_default'),
]; ];
// Add standard fields
foreach ($standard_fields as $field) {
$value = $request->get_param($field);
if ($field === 'email') {
$address[$field] = sanitize_email($value);
} else {
$address[$field] = sanitize_text_field($value);
}
}
// Add any custom fields (like destination_id from Rajaongkir)
$all_params = $request->get_json_params();
if (is_array($all_params)) {
foreach ($all_params as $key => $value) {
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
// Store custom field
$address[$key] = is_string($value) ? sanitize_text_field($value) : $value;
}
}
}
// If this is set as default, unset other defaults of the same type // If this is set as default, unset other defaults of the same type
if ($address['is_default']) { if ($address['is_default']) {
foreach ($addresses as &$addr) { foreach ($addresses as &$addr) {
@@ -138,22 +152,36 @@ class AddressController {
if ($addr['id'] === $address_id) { if ($addr['id'] === $address_id) {
$found = true; $found = true;
// Update fields // Standard address fields
$addr['label'] = sanitize_text_field($request->get_param('label')); $standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$addr['type'] = sanitize_text_field($request->get_param('type')); $reserved_fields = ['id', 'label', 'type', 'is_default'];
$addr['first_name'] = sanitize_text_field($request->get_param('first_name'));
$addr['last_name'] = sanitize_text_field($request->get_param('last_name')); // Update standard meta fields
$addr['company'] = sanitize_text_field($request->get_param('company')); $addr['label'] = sanitize_text_field($request->get_param('label'));
$addr['address_1'] = sanitize_text_field($request->get_param('address_1')); $addr['type'] = sanitize_text_field($request->get_param('type'));
$addr['address_2'] = sanitize_text_field($request->get_param('address_2'));
$addr['city'] = sanitize_text_field($request->get_param('city'));
$addr['state'] = sanitize_text_field($request->get_param('state'));
$addr['postcode'] = sanitize_text_field($request->get_param('postcode'));
$addr['country'] = sanitize_text_field($request->get_param('country'));
$addr['email'] = sanitize_email($request->get_param('email'));
$addr['phone'] = sanitize_text_field($request->get_param('phone'));
$addr['is_default'] = (bool) $request->get_param('is_default'); $addr['is_default'] = (bool) $request->get_param('is_default');
// Update standard fields
foreach ($standard_fields as $field) {
$value = $request->get_param($field);
if ($field === 'email') {
$addr[$field] = sanitize_email($value);
} else {
$addr[$field] = sanitize_text_field($value);
}
}
// Update any custom fields (like destination_id from Rajaongkir)
$all_params = $request->get_json_params();
if (is_array($all_params)) {
foreach ($all_params as $key => $value) {
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
// Store/update custom field
$addr[$key] = is_string($value) ? sanitize_text_field($value) : $value;
}
}
}
// If this is set as default, unset other defaults of the same type // If this is set as default, unset other defaults of the same type
if ($addr['is_default']) { if ($addr['is_default']) {
foreach ($addresses as &$other_addr) { foreach ($addresses as &$other_addr) {

View File

@@ -197,7 +197,13 @@ class Assets {
// Determine SPA base path for BrowserRouter // Determine SPA base path for BrowserRouter
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0; $spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = $spa_page_id ? get_post($spa_page_id) : null; $spa_page = $spa_page_id ? get_post($spa_page_id) : null;
$base_path = $spa_page ? '/' . $spa_page->post_name : '/store';
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
// If SPA is frontpage, base path is /, otherwise use page slug
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
// Check if BrowserRouter is enabled (default: true for SEO) // Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true; $use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
@@ -249,6 +255,16 @@ class Assets {
private static function should_load_assets() { private static function should_load_assets() {
global $post; global $post;
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
return true;
}
// Check if we're on a frontpage SPA route (by URL detection)
if (self::is_frontpage_spa_route()) {
return true;
}
// First check: Is this a designated SPA page? // First check: Is this a designated SPA page?
if (self::is_spa_page()) { if (self::is_spa_page()) {
return true; return true;
@@ -366,6 +382,51 @@ class Assets {
return false; return false;
} }
/**
* Check if current request is a frontpage SPA route
* Used to detect SPA routes by URL when SPA page is set as frontpage
*/
private static function is_frontpage_spa_route() {
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return false;
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
// Check exact matches
if (in_array($path, $spa_routes)) {
return true;
}
// Check path prefixes
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
return true;
}
}
return false;
}
/** /**
* Dequeue conflicting scripts when SPA is active * Dequeue conflicting scripts when SPA is active
*/ */

View File

@@ -35,6 +35,9 @@ class TemplateOverride
// Redirect WooCommerce pages to SPA routes early (before template loads) // Redirect WooCommerce pages to SPA routes early (before template loads)
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5); add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20) // Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does // This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10); add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
@@ -68,7 +71,7 @@ class TemplateOverride
/** /**
* Register rewrite rules for BrowserRouter SEO * Register rewrite rules for BrowserRouter SEO
* Catches all /store/* routes and serves the SPA page * Catches all SPA routes and serves the SPA page
*/ */
public static function register_spa_rewrite_rules() public static function register_spa_rewrite_rules()
{ {
@@ -89,13 +92,82 @@ class TemplateOverride
$spa_slug = $spa_page->post_name; $spa_slug = $spa_page->post_name;
// Rewrite /store/anything to serve the SPA page // Check if SPA page is set as WordPress frontpage
// React Router handles the path after that $frontpage_id = (int) get_option('page_on_front');
add_rewrite_rule( $is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]', if ($is_spa_frontpage) {
'top' // When SPA is frontpage, add root-level routes
); // /shop, /shop/* → SPA page
add_rewrite_rule(
'^shop/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop',
'top'
);
add_rewrite_rule(
'^shop/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
'top'
);
// /product/* → SPA page
add_rewrite_rule(
'^product/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
'top'
);
// /cart → SPA page
add_rewrite_rule(
'^cart/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
'top'
);
// /checkout → SPA page
add_rewrite_rule(
'^checkout/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
'top'
);
// /my-account, /my-account/* → SPA page
add_rewrite_rule(
'^my-account/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account',
'top'
);
add_rewrite_rule(
'^my-account/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
'top'
);
// /login, /register, /reset-password → SPA page
add_rewrite_rule(
'^login/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=login',
'top'
);
add_rewrite_rule(
'^register/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=register',
'top'
);
add_rewrite_rule(
'^reset-password/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top'
);
} else {
// Rewrite /slug/anything to serve the SPA page
// React Router handles the path after that
add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
'top'
);
}
// Register query var for the SPA path // Register query var for the SPA path
add_filter('query_vars', function($vars) { add_filter('query_vars', function($vars) {
@@ -174,6 +246,12 @@ class TemplateOverride
return; // No SPA page configured return; // No SPA page configured
} }
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
return;
}
// Already on SPA page, don't redirect // Already on SPA page, don't redirect
global $post; global $post;
if ($post && $post->ID == $spa_page_id) { if ($post && $post->ID == $spa_page_id) {
@@ -224,6 +302,88 @@ class TemplateOverride
} }
} }
/**
* Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes
* and serve the SPA template directly (bypasses WooCommerce templates)
*/
public static function serve_spa_for_frontpage_routes()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return; // SPA is not frontpage, let normal routing handle it
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes that should be intercepted when SPA is frontpage
$spa_routes = [
'/', // Frontpage itself
'/shop', // Shop page
'/cart', // Cart page
'/checkout', // Checkout page
'/my-account', // Account page
'/login', // Login page
'/register', // Register page
'/reset-password', // Password reset
];
// Check for exact matches or path prefixes
$should_serve_spa = false;
// Check exact matches
if (in_array($path, $spa_routes)) {
$should_serve_spa = true;
}
// Check path prefixes (for sub-routes)
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
$should_serve_spa = true;
break;
}
}
// Not a SPA route
if (!$should_serve_spa) {
return;
}
// Prevent caching for dynamic SPA content
nocache_headers();
// Load the SPA template directly and exit
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
// Set up minimal WordPress environment for the template
status_header(200);
// Define constant to tell Assets to load unconditionally
if (!defined('WOONOOW_SERVE_SPA')) {
define('WOONOOW_SERVE_SPA', true);
}
// Include the SPA template
include $spa_template;
exit;
}
}
/** /**
* Disable canonical redirects for SPA routes * Disable canonical redirects for SPA routes
* This prevents WordPress from redirecting /product/slug URLs * This prevents WordPress from redirecting /product/slug URLs
@@ -406,17 +566,25 @@ class TemplateOverride
private static function is_spa_page() private static function is_spa_page()
{ {
global $post; global $post;
if (!$post) {
return false;
}
// Get SPA settings from appearance // Get SPA settings from appearance
$appearance_settings = get_option('woonoow_appearance_settings', []); $appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0; $spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full'; $spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only return true if spa_mode is 'full' AND we're on the SPA page // Only check if spa_mode is 'full' and SPA page is configured
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) { if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if current page is the SPA page
if ($post && $post->ID == $spa_page_id) {
return true;
}
// Check if SPA page is set as WordPress frontpage and we're on frontpage
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id && is_front_page()) {
return true; return true;
} }