feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)
This commit is contained in:
@@ -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 + '/';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user