feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)
This commit is contained in:
270
admin-spa/src/components/DynamicCheckoutField.tsx
Normal file
270
admin-spa/src/components/DynamicCheckoutField.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { api } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface CheckoutField {
|
||||
key: string;
|
||||
fieldset: 'billing' | 'shipping' | 'account' | 'order';
|
||||
type: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
hidden: boolean;
|
||||
class?: string[];
|
||||
priority: number;
|
||||
options?: Record<string, string> | null;
|
||||
custom: boolean;
|
||||
autocomplete?: string;
|
||||
validate?: string[];
|
||||
input_class?: string[];
|
||||
custom_attributes?: Record<string, string>;
|
||||
default?: string;
|
||||
// For searchable_select type
|
||||
search_endpoint?: string | null;
|
||||
search_param?: string;
|
||||
min_chars?: number;
|
||||
}
|
||||
|
||||
interface DynamicCheckoutFieldProps {
|
||||
field: CheckoutField;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
countryOptions?: { value: string; label: string }[];
|
||||
stateOptions?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
interface SearchOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DynamicCheckoutField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
countryOptions = [],
|
||||
stateOptions = [],
|
||||
}: DynamicCheckoutFieldProps) {
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Handle API search for searchable_select
|
||||
const handleApiSearch = async (searchTerm: string) => {
|
||||
if (!field.search_endpoint) return;
|
||||
|
||||
const minChars = field.min_chars || 2;
|
||||
if (searchTerm.length < minChars) {
|
||||
setSearchOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const param = field.search_param || 'search';
|
||||
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
|
||||
setSearchOptions(Array.isArray(results) ? results : []);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchOptions([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render hidden fields
|
||||
if (field.hidden || field.type === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get field key without prefix (billing_, shipping_)
|
||||
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
|
||||
|
||||
// Determine CSS classes
|
||||
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
|
||||
field.class?.includes('form-row-wide');
|
||||
const wrapperClass = isWide ? 'md:col-span-2' : '';
|
||||
|
||||
// Render based on type
|
||||
const renderInput = () => {
|
||||
switch (field.type) {
|
||||
case 'country':
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || 'Select country'}
|
||||
disabled={countryOptions.length <= 1}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'state':
|
||||
return stateOptions.length > 0 ? (
|
||||
<SearchableSelect
|
||||
options={stateOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || 'Select state'}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
if (field.options && Object.keys(field.options).length > 0) {
|
||||
const options = Object.entries(field.options).map(([val, label]) => ({
|
||||
value: val,
|
||||
label: String(label),
|
||||
}));
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || `Select ${field.label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'searchable_select':
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={searchOptions}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
// Store label for display
|
||||
const selected = searchOptions.find(o => o.value === v);
|
||||
if (selected) {
|
||||
const event = new CustomEvent('woonoow:field_label', {
|
||||
detail: { key: field.key + '_label', value: selected.label }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}}
|
||||
onSearch={handleApiSearch}
|
||||
isSearching={isSearching}
|
||||
placeholder={field.placeholder || `Search ${field.label}...`}
|
||||
emptyLabel={
|
||||
isSearching
|
||||
? 'Searching...'
|
||||
: `Type at least ${field.min_chars || 2} characters to search`
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === '1' || value === 'true'}
|
||||
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
if (field.options) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(field.options).map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.key}
|
||||
value={val}
|
||||
checked={value === val}
|
||||
onChange={() => onChange(val)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{String(label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'email':
|
||||
return (
|
||||
<Input
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete || 'email'}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tel':
|
||||
return (
|
||||
<Input
|
||||
type="tel"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete || 'tel'}
|
||||
/>
|
||||
);
|
||||
|
||||
// Default: text input
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render label for checkbox (it's inline)
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
||||
|
||||
// --- Types ------------------------------------------------------------
|
||||
export type CountryOption = { code: string; name: string };
|
||||
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
|
||||
customer_note?: string;
|
||||
currency?: string;
|
||||
currency_symbol?: string;
|
||||
totals?: {
|
||||
total_items?: number;
|
||||
total_shipping?: number;
|
||||
total_tax?: number;
|
||||
total_discount?: number;
|
||||
total?: number;
|
||||
shipping?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrderPayload = {
|
||||
@@ -91,6 +100,7 @@ export type OrderPayload = {
|
||||
customer_note?: string;
|
||||
register_as_member?: boolean;
|
||||
coupons?: string[];
|
||||
custom_fields?: Record<string, string>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -189,6 +199,9 @@ export default function OrderForm({
|
||||
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
||||
const [couponValidating, setCouponValidating] = React.useState(false);
|
||||
|
||||
// Custom field values (for plugin fields like destination_id)
|
||||
const [customFieldData, setCustomFieldData] = React.useState<Record<string, string>>({});
|
||||
|
||||
// Fetch dynamic checkout fields based on cart items
|
||||
const { data: checkoutFields } = useQuery({
|
||||
queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))],
|
||||
@@ -201,10 +214,46 @@ export default function OrderForm({
|
||||
enabled: items.length > 0,
|
||||
});
|
||||
|
||||
// Apply default values from API for hidden fields (like customer checkout does)
|
||||
React.useEffect(() => {
|
||||
if (!checkoutFields?.fields) return;
|
||||
|
||||
// Initialize custom field defaults
|
||||
const customDefaults: Record<string, string> = {};
|
||||
checkoutFields.fields.forEach((field: any) => {
|
||||
if (field.default && field.custom) {
|
||||
customDefaults[field.key] = field.default;
|
||||
}
|
||||
});
|
||||
if (Object.keys(customDefaults).length > 0) {
|
||||
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
|
||||
}
|
||||
|
||||
// Set billing country default for hidden fields (e.g., Indonesia-only stores)
|
||||
const billingCountryField = checkoutFields.fields.find((f: any) => f.key === 'billing_country');
|
||||
if ((billingCountryField?.type === 'hidden' || billingCountryField?.hidden) && billingCountryField.default && !bCountry) {
|
||||
setBCountry(billingCountryField.default);
|
||||
}
|
||||
|
||||
// Set shipping country default for hidden fields
|
||||
const shippingCountryField = checkoutFields.fields.find((f: any) => f.key === 'shipping_country');
|
||||
if ((shippingCountryField?.type === 'hidden' || shippingCountryField?.hidden) && shippingCountryField.default && !shippingData.country) {
|
||||
setShippingData(prev => ({ ...prev, country: shippingCountryField.default }));
|
||||
}
|
||||
}, [checkoutFields?.fields]);
|
||||
|
||||
// Get effective shipping address (use billing if not shipping to different address)
|
||||
const effectiveShippingAddress = React.useMemo(() => {
|
||||
// Get destination_id from custom fields (Rajaongkir)
|
||||
const destinationId = shipDiff
|
||||
? customFieldData['shipping_destination_id']
|
||||
: customFieldData['billing_destination_id'];
|
||||
|
||||
if (shipDiff) {
|
||||
return shippingData;
|
||||
return {
|
||||
...shippingData,
|
||||
destination_id: destinationId || undefined,
|
||||
};
|
||||
}
|
||||
// Use billing address
|
||||
return {
|
||||
@@ -214,22 +263,19 @@ export default function OrderForm({
|
||||
postcode: bPost,
|
||||
address_1: bAddr1,
|
||||
address_2: '',
|
||||
destination_id: destinationId || undefined,
|
||||
};
|
||||
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]);
|
||||
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1, customFieldData]);
|
||||
|
||||
// Check if shipping address is complete enough to calculate rates
|
||||
// Should match customer checkout: just needs country to fetch (destination_id can provide more specific rates)
|
||||
const isShippingAddressComplete = React.useMemo(() => {
|
||||
const addr = effectiveShippingAddress;
|
||||
// Need at minimum: country, state (if applicable), city
|
||||
if (!addr.country) return false;
|
||||
if (!addr.city) return false;
|
||||
// If country has states, require state
|
||||
const countryStates = states[addr.country];
|
||||
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [effectiveShippingAddress, states]);
|
||||
// Need at minimum: country OR destination_id
|
||||
// destination_id from Rajaongkir is sufficient to calculate shipping
|
||||
if (addr.destination_id) return true;
|
||||
return !!addr.country;
|
||||
}, [effectiveShippingAddress]);
|
||||
|
||||
// Debounce city input to avoid hitting backend on every keypress
|
||||
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
||||
@@ -244,7 +290,7 @@ export default function OrderForm({
|
||||
|
||||
// Calculate shipping rates dynamically
|
||||
const { data: shippingRates, isLoading: shippingLoading } = useQuery({
|
||||
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode],
|
||||
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode, effectiveShippingAddress.destination_id],
|
||||
queryFn: async () => {
|
||||
return api.post('/shipping/calculate', {
|
||||
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
||||
@@ -424,6 +470,43 @@ export default function OrderForm({
|
||||
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
|
||||
const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||
|
||||
// Helper to get billing field config from API - returns null if hidden
|
||||
const getBillingField = (key: string) => {
|
||||
if (!checkoutFields?.fields) return { label: '', required: false }; // fallback when API not loaded
|
||||
const field = checkoutFields.fields.find((f: any) => f.key === key);
|
||||
// Check both hidden flag and type === 'hidden'
|
||||
if (!field || field.hidden || field.type === 'hidden') return null;
|
||||
return field;
|
||||
};
|
||||
|
||||
// Helper to check if billing field should have full width
|
||||
const isBillingFieldWide = (key: string) => {
|
||||
const field = getBillingField(key);
|
||||
if (!field) return false;
|
||||
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
|
||||
return hasFormRowWide || ['billing_address_1', 'billing_address_2'].includes(key);
|
||||
};
|
||||
|
||||
// Derive custom fields from API (for plugin fields like destination_id)
|
||||
const billingCustomFields = React.useMemo(() => {
|
||||
if (!checkoutFields?.fields) return [];
|
||||
return checkoutFields.fields
|
||||
.filter((f: any) => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden')
|
||||
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
||||
}, [checkoutFields?.fields]);
|
||||
|
||||
const shippingCustomFields = React.useMemo(() => {
|
||||
if (!checkoutFields?.fields) return [];
|
||||
return checkoutFields.fields
|
||||
.filter((f: any) => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden')
|
||||
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
||||
}, [checkoutFields?.fields]);
|
||||
|
||||
// Helper to handle custom field changes
|
||||
const handleCustomFieldChange = (key: string, value: string) => {
|
||||
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -453,6 +536,7 @@ export default function OrderForm({
|
||||
customer_note: note || undefined,
|
||||
items: itemsEditable ? items : undefined,
|
||||
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
||||
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1006,138 +1090,229 @@ export default function OrderForm({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Billing address - only show full address for physical products */}
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||
{mode === 'create' && (
|
||||
<SearchableSelect
|
||||
options={customers.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: (
|
||||
<div className="leading-tight">
|
||||
<div className="font-medium">{c.name || c.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||
</div>
|
||||
),
|
||||
searchText: `${c.name} ${c.email}`,
|
||||
customer: c,
|
||||
}))}
|
||||
value={undefined}
|
||||
onChange={async (val: string) => {
|
||||
const customer = customers.find((c: any) => String(c.id) === val);
|
||||
if (!customer) return;
|
||||
{/* Billing address - only show when items are added (so checkout fields API is loaded) */}
|
||||
{items.length > 0 && (
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||
{mode === 'create' && (
|
||||
<SearchableSelect
|
||||
options={customers.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: (
|
||||
<div className="leading-tight">
|
||||
<div className="font-medium">{c.name || c.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||
</div>
|
||||
),
|
||||
searchText: `${c.name} ${c.email}`,
|
||||
customer: c,
|
||||
}))}
|
||||
value={undefined}
|
||||
onChange={async (val: string) => {
|
||||
const customer = customers.find((c: any) => String(c.id) === val);
|
||||
if (!customer) return;
|
||||
|
||||
// Fetch full customer data
|
||||
try {
|
||||
const data = await CustomersApi.searchByEmail(customer.email);
|
||||
if (data.found && data.billing) {
|
||||
// Always fill name, email, phone
|
||||
setBFirst(data.billing.first_name || data.first_name || '');
|
||||
setBLast(data.billing.last_name || data.last_name || '');
|
||||
setBEmail(data.email || '');
|
||||
setBPhone(data.billing.phone || '');
|
||||
// Fetch full customer data
|
||||
try {
|
||||
const data = await CustomersApi.searchByEmail(customer.email);
|
||||
if (data.found && data.billing) {
|
||||
// Always fill name, email, phone
|
||||
setBFirst(data.billing.first_name || data.first_name || '');
|
||||
setBLast(data.billing.last_name || data.last_name || '');
|
||||
setBEmail(data.email || '');
|
||||
setBPhone(data.billing.phone || '');
|
||||
|
||||
// Only fill address fields if cart has physical products
|
||||
if (hasPhysicalProduct) {
|
||||
setBAddr1(data.billing.address_1 || '');
|
||||
setBCity(data.billing.city || '');
|
||||
setBPost(data.billing.postcode || '');
|
||||
setBCountry(data.billing.country || bCountry);
|
||||
setBState(data.billing.state || '');
|
||||
// Only fill address fields if cart has physical products
|
||||
if (hasPhysicalProduct) {
|
||||
setBAddr1(data.billing.address_1 || '');
|
||||
setBCity(data.billing.city || '');
|
||||
setBPost(data.billing.postcode || '');
|
||||
setBCountry(data.billing.country || bCountry);
|
||||
setBState(data.billing.state || '');
|
||||
|
||||
// Autofill shipping if available
|
||||
if (data.shipping && data.shipping.address_1) {
|
||||
setShipDiff(true);
|
||||
setShippingData({
|
||||
first_name: data.shipping.first_name || '',
|
||||
last_name: data.shipping.last_name || '',
|
||||
address_1: data.shipping.address_1 || '',
|
||||
city: data.shipping.city || '',
|
||||
postcode: data.shipping.postcode || '',
|
||||
country: data.shipping.country || bCountry,
|
||||
state: data.shipping.state || '',
|
||||
});
|
||||
// Autofill shipping if available
|
||||
if (data.shipping && data.shipping.address_1) {
|
||||
setShipDiff(true);
|
||||
setShippingData({
|
||||
first_name: data.shipping.first_name || '',
|
||||
last_name: data.shipping.last_name || '',
|
||||
address_1: data.shipping.address_1 || '',
|
||||
city: data.shipping.city || '',
|
||||
postcode: data.shipping.postcode || '',
|
||||
country: data.shipping.country || bCountry,
|
||||
state: data.shipping.state || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark customer as selected
|
||||
setSelectedCustomerId(data.user_id);
|
||||
}
|
||||
|
||||
// Mark customer as selected
|
||||
setSelectedCustomerId(data.user_id);
|
||||
} catch (e) {
|
||||
console.error('Customer autofill error:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Customer autofill error:', e);
|
||||
}
|
||||
|
||||
setCustomerSearchQ('');
|
||||
}}
|
||||
onSearch={setCustomerSearchQ}
|
||||
placeholder={__('Search customer...')}
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{__('First name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bFirst} onChange={e => setBFirst(e.target.value)} />
|
||||
setCustomerSearchQ('');
|
||||
}}
|
||||
onSearch={setCustomerSearchQ}
|
||||
placeholder={__('Search customer...')}
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Last name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e => setBLast(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Email')}</Label>
|
||||
<Input
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
className="rounded-md border px-3 py-2 appearance-none"
|
||||
value={bEmail}
|
||||
onChange={e => setBEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Phone')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e => setBPhone(e.target.value)} />
|
||||
</div>
|
||||
{/* Only show full address fields for physical products */}
|
||||
{hasPhysicalProduct && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<Label>{__('Address')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e => setBAddr1(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('City')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e => setBCity(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Postcode')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e => setBPost(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Country')}</Label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={bCountry}
|
||||
onChange={setBCountry}
|
||||
placeholder={countries.length ? __('Select country') : __('No countries')}
|
||||
disabled={oneCountryOnly}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Dynamic billing fields - respects API visibility, labels, required status */}
|
||||
{getBillingField('billing_first_name') && (
|
||||
<div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_first_name')?.label || __('First name')}
|
||||
{getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bFirst}
|
||||
onChange={e => setBFirst(e.target.value)}
|
||||
required={getBillingField('billing_first_name')?.required}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('State/Province')}</Label>
|
||||
<SearchableSelect
|
||||
options={bStateOptions}
|
||||
value={bState}
|
||||
onChange={setBState}
|
||||
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
||||
disabled={!bStateOptions.length}
|
||||
)}
|
||||
{getBillingField('billing_last_name') && (
|
||||
<div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_last_name')?.label || __('Last name')}
|
||||
{getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bLast}
|
||||
onChange={e => setBLast(e.target.value)}
|
||||
required={getBillingField('billing_last_name')?.required}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
{getBillingField('billing_email') && (
|
||||
<div className={isBillingFieldWide('billing_email') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_email')?.label || __('Email')}
|
||||
{getBillingField('billing_email')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
className="rounded-md border px-3 py-2 appearance-none"
|
||||
value={bEmail}
|
||||
onChange={e => setBEmail(e.target.value)}
|
||||
required={getBillingField('billing_email')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_phone') && (
|
||||
<div className={isBillingFieldWide('billing_phone') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_phone')?.label || __('Phone')}
|
||||
{getBillingField('billing_phone')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bPhone}
|
||||
onChange={e => setBPhone(e.target.value)}
|
||||
required={getBillingField('billing_phone')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Address fields - only shown for physical products AND when not hidden by API */}
|
||||
{hasPhysicalProduct && (
|
||||
<>
|
||||
{getBillingField('billing_address_1') && (
|
||||
<div className="md:col-span-2">
|
||||
<Label>
|
||||
{getBillingField('billing_address_1')?.label || __('Address')}
|
||||
{getBillingField('billing_address_1')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bAddr1}
|
||||
onChange={e => setBAddr1(e.target.value)}
|
||||
required={getBillingField('billing_address_1')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_city') && (
|
||||
<div className={isBillingFieldWide('billing_city') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_city')?.label || __('City')}
|
||||
{getBillingField('billing_city')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bCity}
|
||||
onChange={e => setBCity(e.target.value)}
|
||||
required={getBillingField('billing_city')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_postcode') && (
|
||||
<div className={isBillingFieldWide('billing_postcode') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_postcode')?.label || __('Postcode')}
|
||||
{getBillingField('billing_postcode')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bPost}
|
||||
onChange={e => setBPost(e.target.value)}
|
||||
required={getBillingField('billing_postcode')?.required}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_country') && (
|
||||
<div className={isBillingFieldWide('billing_country') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_country')?.label || __('Country')}
|
||||
{getBillingField('billing_country')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={bCountry}
|
||||
onChange={setBCountry}
|
||||
placeholder={countries.length ? __('Select country') : __('No countries')}
|
||||
disabled={oneCountryOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_state') && (
|
||||
<div className={isBillingFieldWide('billing_state') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_state')?.label || __('State/Province')}
|
||||
{getBillingField('billing_state')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
options={bStateOptions}
|
||||
value={bState}
|
||||
onChange={setBState}
|
||||
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
||||
disabled={!bStateOptions.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Billing custom fields from plugins (e.g., destination_id from Rajaongkir) */}
|
||||
{hasPhysicalProduct && billingCustomFields.map((field: CheckoutField) => (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={bStateOptions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional: Only show address fields and shipping for physical products */}
|
||||
{!hasPhysicalProduct && (
|
||||
@@ -1162,7 +1337,7 @@ export default function OrderForm({
|
||||
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{checkoutFields.fields
|
||||
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden)
|
||||
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden && f.type !== 'hidden')
|
||||
.sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
|
||||
.map((field: any) => {
|
||||
// Check for full width: address fields or form-row-wide class from PHP
|
||||
@@ -1170,6 +1345,20 @@ export default function OrderForm({
|
||||
const isWide = hasFormRowWide || ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
|
||||
const fieldKey = field.key.replace('shipping_', '');
|
||||
|
||||
// For searchable_select, DynamicCheckoutField renders its own label wrapper
|
||||
if (field.type === 'searchable_select') {
|
||||
return (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
@@ -1208,8 +1397,20 @@ export default function OrderForm({
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={shippingData[fieldKey] || ''}
|
||||
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
||||
value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
|
||||
onChange={(e) => field.custom
|
||||
? handleCustomFieldChange(field.key, e.target.value)
|
||||
: setShippingData({ ...shippingData, [fieldKey]: e.target.value })
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
) : field.custom ? (
|
||||
// For other custom field types, store in customFieldData
|
||||
<Input
|
||||
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(e) => handleCustomFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store'; // This should ideally come from settings
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -33,7 +33,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
params.set('quantity', quantity.toString());
|
||||
}
|
||||
params.set('redirect', redirect);
|
||||
|
||||
|
||||
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -48,17 +48,17 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
}
|
||||
};
|
||||
|
||||
const LinkRow = ({
|
||||
label,
|
||||
link,
|
||||
description
|
||||
}: {
|
||||
label: string;
|
||||
link: string;
|
||||
const LinkRow = ({
|
||||
label,
|
||||
link,
|
||||
description
|
||||
}: {
|
||||
label: string;
|
||||
link: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const isCopied = copiedLink === link;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -137,13 +137,13 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
<div className="border-b pb-2">
|
||||
<h4 className="font-medium">Simple Product Links</h4>
|
||||
</div>
|
||||
|
||||
|
||||
<LinkRow
|
||||
label="Add to Cart"
|
||||
link={generateLink(undefined, 'cart')}
|
||||
description="Adds product to cart and shows cart page"
|
||||
/>
|
||||
|
||||
|
||||
<LinkRow
|
||||
label="Direct to Checkout"
|
||||
link={generateLink(undefined, 'checkout')}
|
||||
@@ -172,22 +172,22 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
(ID: {variation.id})
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
|
||||
<div className="p-4 pt-0 space-y-3 border-t">
|
||||
<LinkRow
|
||||
label="Add to Cart"
|
||||
link={generateLink(variation.id, 'cart')}
|
||||
/>
|
||||
|
||||
|
||||
<LinkRow
|
||||
label="Direct to Checkout"
|
||||
link={generateLink(variation.id, 'checkout')}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function GeneralTab({
|
||||
// Copy link state and helpers
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store';
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateSimpleLink = (redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
if (!productId) return '';
|
||||
|
||||
@@ -46,7 +46,7 @@ export function VariationsTab({
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store';
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
if (!productId) return '';
|
||||
|
||||
@@ -59,16 +59,12 @@ const getAppearanceSettings = () => {
|
||||
const getInitialRoute = () => {
|
||||
const appEl = document.getElementById('woonoow-customer-app');
|
||||
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
|
||||
};
|
||||
|
||||
// Router wrapper component that uses hooks requiring Router context
|
||||
function AppRoutes() {
|
||||
const initialRoute = getInitialRoute();
|
||||
console.log('[WooNooW Customer] Using initial route:', initialRoute);
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function DynamicCheckoutField({
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
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)}
|
||||
placeholder={field.placeholder}
|
||||
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}
|
||||
required={field.required}
|
||||
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}
|
||||
required={field.required}
|
||||
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)}
|
||||
placeholder={field.placeholder}
|
||||
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}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SearchableSelect({
|
||||
type="button"
|
||||
role="combobox"
|
||||
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",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useAddToCartFromUrl() {
|
||||
const hash = window.location.hash;
|
||||
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
|
||||
const productId = hashParams.get('add-to-cart');
|
||||
|
||||
|
||||
if (!productId) return;
|
||||
|
||||
const variationId = hashParams.get('variation_id');
|
||||
@@ -34,41 +34,29 @@ export function useAddToCartFromUrl() {
|
||||
|
||||
// Create unique key for this add-to-cart request
|
||||
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
|
||||
|
||||
|
||||
// Skip if already processed
|
||||
if (processedRef.current.has(requestKey)) {
|
||||
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Add to cart from URL:', {
|
||||
productId,
|
||||
variationId,
|
||||
quantity,
|
||||
redirect,
|
||||
fullUrl: window.location.href,
|
||||
requestKey,
|
||||
});
|
||||
|
||||
// Mark as processed
|
||||
processedRef.current.add(requestKey);
|
||||
|
||||
|
||||
addToCart(productId, variationId, quantity)
|
||||
.then((cartData) => {
|
||||
// Update cart store with fresh data from API
|
||||
if (cartData) {
|
||||
setCart(cartData);
|
||||
console.log('[WooNooW] Cart updated with fresh data:', cartData);
|
||||
}
|
||||
|
||||
|
||||
// Remove URL parameters after adding to cart
|
||||
const currentPath = window.location.hash.split('?')[0];
|
||||
window.location.hash = currentPath;
|
||||
|
||||
|
||||
// Navigate based on redirect parameter
|
||||
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
|
||||
if (!location.pathname.includes(targetPage)) {
|
||||
console.log(`[WooNooW] Navigating to ${targetPage}`);
|
||||
navigate(targetPage);
|
||||
}
|
||||
})
|
||||
@@ -98,8 +86,6 @@ async function addToCart(
|
||||
body.variation_id = parseInt(variationId, 10);
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Adding to cart:', body);
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -116,8 +102,7 @@ async function addToCart(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[WooNooW] Product added to cart:', data);
|
||||
|
||||
|
||||
// API returns {message, cart_item_key, cart} on success
|
||||
if (data.cart_item_key && data.cart) {
|
||||
toast.success(data.message || 'Product added to cart');
|
||||
|
||||
@@ -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 { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { DynamicCheckoutField } from '@/components/DynamicCheckoutField';
|
||||
|
||||
interface Address {
|
||||
id: number;
|
||||
@@ -20,6 +21,35 @@ interface Address {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
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() {
|
||||
@@ -27,23 +57,94 @@ export default function Addresses() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Address>>({
|
||||
const [formData, setFormData] = useState<Record<string, any>>({
|
||||
label: '',
|
||||
type: 'both',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'ID',
|
||||
email: '',
|
||||
phone: '',
|
||||
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(() => {
|
||||
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 = () => {
|
||||
setEditingAddress(null);
|
||||
setFormData({
|
||||
// Initialize with defaults from API fields
|
||||
const defaults: Record<string, any> = {
|
||||
label: '',
|
||||
type: 'both',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'ID',
|
||||
email: '',
|
||||
phone: '',
|
||||
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);
|
||||
};
|
||||
|
||||
const handleEdit = (address: 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);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
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) {
|
||||
await api.put(`/account/addresses/${editingAddress.id}`, formData);
|
||||
await api.put(`/account/addresses/${editingAddress.id}`, payload);
|
||||
toast.success('Address updated successfully');
|
||||
} else {
|
||||
await api.post('/account/addresses', formData);
|
||||
await api.post('/account/addresses', payload);
|
||||
toast.success('Address added successfully');
|
||||
}
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -245,161 +399,69 @@ export default function Addresses() {
|
||||
{editingAddress ? 'Edit Address' : 'Add New Address'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
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">
|
||||
{loadingFields ? (
|
||||
<div className="py-8 text-center text-gray-500">Loading form fields...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Label field - always shown */}
|
||||
<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
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
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">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>
|
||||
<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">
|
||||
{/* Address Type - always shown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.city}
|
||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||
<label className="block text-sm font-medium mb-1">Address Type *</label>
|
||||
<select
|
||||
value={formData.type || 'both'}
|
||||
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>
|
||||
<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
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
checked={formData.is_default || false}
|
||||
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="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">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -80,10 +80,12 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
});
|
||||
|
||||
// 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) {
|
||||
// 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 + '/';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -712,7 +712,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_first_name')?.required}
|
||||
value={billingData.firstName}
|
||||
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>
|
||||
)}
|
||||
@@ -724,7 +724,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_last_name')?.required}
|
||||
value={billingData.lastName}
|
||||
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>
|
||||
)}
|
||||
@@ -736,7 +736,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_email')?.required}
|
||||
value={billingData.email}
|
||||
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>
|
||||
)}
|
||||
@@ -748,7 +748,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_phone')?.required}
|
||||
value={billingData.phone}
|
||||
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>
|
||||
)}
|
||||
@@ -764,7 +764,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_address_1')?.required}
|
||||
value={billingData.address}
|
||||
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>
|
||||
)}
|
||||
@@ -777,7 +777,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_city')?.required}
|
||||
value={billingData.city}
|
||||
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>
|
||||
)}
|
||||
@@ -811,7 +811,7 @@ export default function Checkout() {
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
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>
|
||||
@@ -825,7 +825,7 @@ export default function Checkout() {
|
||||
required={getBillingField('billing_postcode')?.required}
|
||||
value={billingData.postcode}
|
||||
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>
|
||||
)}
|
||||
@@ -932,36 +932,43 @@ export default function Checkout() {
|
||||
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
|
||||
{(!selectedShippingAddressId || showShippingForm) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
{/* Dynamic shipping fields using getShippingField like billing */}
|
||||
{getShippingField('shipping_first_name') && (
|
||||
<div className={getFieldWrapperClass('shipping_first_name', 'shipping')}>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_first_name')?.label || 'First Name'} {getShippingField('shipping_first_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required={getShippingField('shipping_first_name')?.required}
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getShippingField('shipping_last_name') && (
|
||||
<div className={getFieldWrapperClass('shipping_last_name', 'shipping')}>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_last_name')?.label || 'Last Name'} {getShippingField('shipping_last_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required={getShippingField('shipping_last_name')?.required}
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getShippingField('shipping_address_1') && (
|
||||
<div className={getFieldWrapperClass('shipping_address_1', 'shipping') || 'md:col-span-2'}>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_address_1')?.label || 'Street Address'} {getShippingField('shipping_address_1')?.required && '*'}</label>
|
||||
<input
|
||||
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 */}
|
||||
{getShippingField('shipping_city') && (
|
||||
<div>
|
||||
@@ -971,7 +978,7 @@ export default function Checkout() {
|
||||
required={getShippingField('shipping_city')?.required}
|
||||
value={shippingData.city}
|
||||
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>
|
||||
)}
|
||||
@@ -1005,7 +1012,7 @@ export default function Checkout() {
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
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>
|
||||
@@ -1019,7 +1026,7 @@ export default function Checkout() {
|
||||
required={getShippingField('shipping_postcode')?.required}
|
||||
value={shippingData.postcode}
|
||||
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>
|
||||
)}
|
||||
@@ -1050,7 +1057,7 @@ export default function Checkout() {
|
||||
value={orderNotes}
|
||||
onChange={(e) => setOrderNotes(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,8 @@ export default function Login() {
|
||||
|
||||
// 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
|
||||
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;
|
||||
// Force page reload to refresh cookies and server-side state
|
||||
window.location.reload();
|
||||
|
||||
@@ -72,7 +72,7 @@ class Assets
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'isAuthenticated' => is_user_logged_in(),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => home_url('/store/'),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
]);
|
||||
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'),
|
||||
'isAuthenticated' => is_user_logged_in(),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => home_url('/store/'),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
]);
|
||||
|
||||
@@ -312,4 +312,21 @@ class Assets
|
||||
// Bump when releasing; in dev we don't cache-bust
|
||||
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/');
|
||||
}
|
||||
}
|
||||
@@ -113,9 +113,14 @@ class Menu {
|
||||
] );
|
||||
|
||||
// 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 );
|
||||
if ( $customer_spa_enabled ) {
|
||||
$store_url = home_url( '/store/' );
|
||||
if ( $customer_spa_enabled && $spa_page) {
|
||||
|
||||
$spa_slug = $spa_page->post_name;
|
||||
$store_url = home_url( '/' . $spa_slug );
|
||||
$wp_admin_bar->add_node( [
|
||||
'id' => 'woonoow-store',
|
||||
'title' => '<span class="ab-icon dashicons-cart"></span><span class="ab-label">' . __( 'Store', 'woonoow' ) . '</span>',
|
||||
|
||||
@@ -133,7 +133,7 @@ class StandaloneAdmin {
|
||||
locale: <?php echo wp_json_encode( get_locale() ); ?>,
|
||||
siteUrl: <?php echo wp_json_encode( home_url() ); ?>,
|
||||
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'; ?>
|
||||
};
|
||||
|
||||
@@ -196,4 +196,21 @@ class StandaloneAdmin {
|
||||
'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/' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,25 +858,49 @@ class OrdersController {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Billing information is required for a healthy order
|
||||
$required_billing_fields = [
|
||||
'first_name' => __( 'Billing first name', 'woonoow' ),
|
||||
'last_name' => __( 'Billing last name', 'woonoow' ),
|
||||
'email' => __( 'Billing email', 'woonoow' ),
|
||||
];
|
||||
// 3. Billing information required based on checkout fields configuration
|
||||
// Get checkout field settings to respect hidden/required status from PHP snippets
|
||||
$checkout_fields = apply_filters( 'woonoow/checkout/fields', [], $items );
|
||||
|
||||
// Address fields only required for physical products
|
||||
if ( $has_physical_product ) {
|
||||
$required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' );
|
||||
$required_billing_fields['city'] = __( 'Billing city', 'woonoow' );
|
||||
$required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' );
|
||||
$required_billing_fields['country'] = __( 'Billing country', 'woonoow' );
|
||||
// Helper to check if a billing field is required
|
||||
$is_field_required = function( $field_key ) use ( $checkout_fields ) {
|
||||
foreach ( $checkout_fields as $field ) {
|
||||
if ( isset( $field['key'] ) && $field['key'] === $field_key ) {
|
||||
// Field is not required if hidden or explicitly not required
|
||||
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' );
|
||||
}
|
||||
|
||||
foreach ( $required_billing_fields as $field => $label ) {
|
||||
if ( empty( $billing[ $field ] ) ) {
|
||||
/* translators: %s: field label */
|
||||
$validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label );
|
||||
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' );
|
||||
}
|
||||
|
||||
// Address fields only required for physical products AND if not hidden
|
||||
if ( $has_physical_product ) {
|
||||
if ( $is_field_required( 'billing_address_1' ) && empty( $billing['address_1'] ) ) {
|
||||
$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') ?? '' );
|
||||
$limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) );
|
||||
|
||||
$args = [ 'limit' => $limit, 'status' => 'publish' ];
|
||||
if ( $s ) { $args['s'] = $s; }
|
||||
|
||||
$prods = wc_get_products( $args );
|
||||
// Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter)
|
||||
$args = [
|
||||
'post_type' => [ 'product' ],
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $limit,
|
||||
];
|
||||
if ( $s ) {
|
||||
$args['s'] = $s;
|
||||
}
|
||||
|
||||
$query = new \WP_Query( $args );
|
||||
$prods = array_filter( array_map( 'wc_get_product', $query->posts ) );
|
||||
$rows = array_map( function( $p ) {
|
||||
$data = [
|
||||
'id' => $p->get_id(),
|
||||
|
||||
@@ -86,25 +86,39 @@ class AddressController {
|
||||
// Generate new ID
|
||||
$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 = [
|
||||
'id' => $new_id,
|
||||
'label' => sanitize_text_field($request->get_param('label')),
|
||||
'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'),
|
||||
];
|
||||
|
||||
// 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 ($address['is_default']) {
|
||||
foreach ($addresses as &$addr) {
|
||||
@@ -138,22 +152,36 @@ class AddressController {
|
||||
if ($addr['id'] === $address_id) {
|
||||
$found = true;
|
||||
|
||||
// Update fields
|
||||
$addr['label'] = sanitize_text_field($request->get_param('label'));
|
||||
$addr['type'] = sanitize_text_field($request->get_param('type'));
|
||||
$addr['first_name'] = sanitize_text_field($request->get_param('first_name'));
|
||||
$addr['last_name'] = sanitize_text_field($request->get_param('last_name'));
|
||||
$addr['company'] = sanitize_text_field($request->get_param('company'));
|
||||
$addr['address_1'] = sanitize_text_field($request->get_param('address_1'));
|
||||
$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'));
|
||||
// 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'];
|
||||
|
||||
// Update standard meta fields
|
||||
$addr['label'] = sanitize_text_field($request->get_param('label'));
|
||||
$addr['type'] = sanitize_text_field($request->get_param('type'));
|
||||
$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 ($addr['is_default']) {
|
||||
foreach ($addresses as &$other_addr) {
|
||||
|
||||
@@ -197,7 +197,13 @@ class Assets {
|
||||
// Determine SPA base path for BrowserRouter
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$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)
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
@@ -249,6 +255,16 @@ class Assets {
|
||||
private static function should_load_assets() {
|
||||
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?
|
||||
if (self::is_spa_page()) {
|
||||
return true;
|
||||
@@ -366,6 +382,51 @@ class Assets {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,9 @@ class TemplateOverride
|
||||
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
||||
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)
|
||||
// This ensures we process add-to-cart before WooCommerce does
|
||||
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
|
||||
@@ -68,7 +71,7 @@ class TemplateOverride
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
@@ -89,13 +92,82 @@ class TemplateOverride
|
||||
|
||||
$spa_slug = $spa_page->post_name;
|
||||
|
||||
// Rewrite /store/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'
|
||||
);
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
|
||||
|
||||
if ($is_spa_frontpage) {
|
||||
// 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
|
||||
add_filter('query_vars', function($vars) {
|
||||
@@ -174,6 +246,12 @@ class TemplateOverride
|
||||
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
|
||||
global $post;
|
||||
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
|
||||
* This prevents WordPress from redirecting /product/slug URLs
|
||||
@@ -406,17 +566,25 @@ class TemplateOverride
|
||||
private static function is_spa_page()
|
||||
{
|
||||
global $post;
|
||||
if (!$post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Get SPA settings from appearance
|
||||
$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 return true if spa_mode is 'full' AND we're on the SPA page
|
||||
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) {
|
||||
// Only check if spa_mode is 'full' and SPA page is configured
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user