feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)

This commit is contained in:
Dwindi Ramadhana
2026-01-10 00:50:32 +07:00
parent d3ec580ec8
commit 3357fbfcf1
20 changed files with 1317 additions and 465 deletions

View File

@@ -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>

View File

@@ -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 + '/';
}
};