feat: implement multiple saved addresses with modal selector in checkout
- Add AddressController with full CRUD API for saved addresses - Implement address management UI in My Account > Addresses - Add modal-based address selector in checkout (Tokopedia-style) - Hide checkout forms when saved address is selected - Add search functionality in address modal - Auto-select default addresses on page load - Fix variable products to show 'Select Options' instead of 'Add to Cart' - Add admin toggle for multiple addresses feature - Clean up debug logs and fix TypeScript errors
This commit is contained in:
205
customer-spa/src/pages/Account/AccountDetails.tsx
Normal file
205
customer-spa/src/pages/Account/AccountDetails.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api/client';
|
||||
|
||||
export default function AccountDetails() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
});
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await api.get<any>('/account/profile');
|
||||
setFormData({
|
||||
firstName: data.first_name || '',
|
||||
lastName: data.last_name || '',
|
||||
email: data.email || '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Load profile error:', error);
|
||||
toast.error('Failed to load profile data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await api.post('/account/profile', {
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
});
|
||||
|
||||
toast.success('Profile updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Save profile error:', error);
|
||||
toast.error('Failed to save profile');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordData.newPassword.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await api.post('/account/password', {
|
||||
current_password: passwordData.currentPassword,
|
||||
new_password: passwordData.newPassword,
|
||||
});
|
||||
|
||||
toast.success('Password updated successfully');
|
||||
setPasswordData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Password update error:', error);
|
||||
toast.error(error.message || 'Failed to update password');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Account Details</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium mb-2">First Name</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium mb-2">Last Name</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Password Change Form - Separate */}
|
||||
<form onSubmit={handlePasswordChange} className="space-y-6 mt-8 pt-8 border-t">
|
||||
<h2 className="text-xl font-semibold">Password Change</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium mb-2">Current Password</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium mb-2">New Password</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={passwordData.newPassword}
|
||||
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={saving}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2">Confirm New Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
442
customer-spa/src/pages/Account/Addresses.tsx
Normal file
442
customer-spa/src/pages/Account/Addresses.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Address {
|
||||
id: number;
|
||||
label: string;
|
||||
type: 'billing' | 'shipping' | 'both';
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company?: string;
|
||||
address_1: string;
|
||||
address_2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function Addresses() {
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Address>>({
|
||||
label: '',
|
||||
type: 'both',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'ID',
|
||||
email: '',
|
||||
phone: '',
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadAddresses();
|
||||
}, []);
|
||||
|
||||
const loadAddresses = async () => {
|
||||
try {
|
||||
const response: any = await api.get('/account/addresses');
|
||||
console.log('API response:', response);
|
||||
console.log('Type of response:', typeof response);
|
||||
console.log('Is array:', Array.isArray(response));
|
||||
console.log('Response keys:', response ? Object.keys(response) : 'null');
|
||||
console.log('Response values:', response ? Object.values(response) : 'null');
|
||||
|
||||
// Handle different response structures
|
||||
let data: Address[] = [];
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
// Direct array response
|
||||
data = response;
|
||||
console.log('Using direct array');
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Log all properties to debug
|
||||
console.log('Checking object properties...');
|
||||
|
||||
// Check common wrapper properties
|
||||
if (Array.isArray(response.data)) {
|
||||
data = response.data;
|
||||
console.log('Using response.data');
|
||||
} else if (Array.isArray(response.addresses)) {
|
||||
data = response.addresses;
|
||||
console.log('Using response.addresses');
|
||||
} else if (response.length !== undefined && typeof response === 'object') {
|
||||
// Might be array-like object, convert to array
|
||||
data = Object.values(response).filter((item: any) => item && typeof item === 'object' && item.id) as Address[];
|
||||
console.log('Converted object to array:', data);
|
||||
} else {
|
||||
console.error('API returned unexpected structure:', response);
|
||||
console.error('Available keys:', Object.keys(response));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Final addresses array:', data);
|
||||
setAddresses(data);
|
||||
} catch (error) {
|
||||
console.error('Load addresses error:', error);
|
||||
toast.error('Failed to load addresses');
|
||||
setAddresses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingAddress(null);
|
||||
setFormData({
|
||||
label: '',
|
||||
type: 'both',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'ID',
|
||||
email: '',
|
||||
phone: '',
|
||||
is_default: false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (address: Address) => {
|
||||
setEditingAddress(address);
|
||||
setFormData(address);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
console.log('Saving address:', formData);
|
||||
if (editingAddress) {
|
||||
console.log('Updating address ID:', editingAddress.id);
|
||||
const response = await api.put(`/account/addresses/${editingAddress.id}`, formData);
|
||||
console.log('Update response:', response);
|
||||
toast.success('Address updated successfully');
|
||||
} else {
|
||||
console.log('Creating new address');
|
||||
const response = await api.post('/account/addresses', formData);
|
||||
console.log('Create response:', response);
|
||||
toast.success('Address added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadAddresses();
|
||||
} catch (error) {
|
||||
console.error('Save address error:', error);
|
||||
toast.error('Failed to save address');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this address?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/account/addresses/${id}`);
|
||||
toast.success('Address deleted successfully');
|
||||
loadAddresses();
|
||||
} catch (error) {
|
||||
console.error('Delete address error:', error);
|
||||
toast.error('Failed to delete address');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/account/addresses/${id}/set-default`, {});
|
||||
toast.success('Default address updated');
|
||||
loadAddresses();
|
||||
} catch (error) {
|
||||
console.error('Set default error:', error);
|
||||
toast.error('Failed to set default address');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-600">Loading addresses...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Addresses</h1>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addresses.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg">
|
||||
<MapPin className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-600 mb-4">No addresses saved yet</p>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Your First Address
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{addresses.map((address) => (
|
||||
<div key={address.id} className="border rounded-lg p-6 relative">
|
||||
{address.is_default && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<Star className="w-5 h-5 text-yellow-500 fill-yellow-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold text-lg">{address.label}</h3>
|
||||
<span className="text-xs text-gray-500 uppercase">
|
||||
{address.type === 'both' ? 'Billing & Shipping' : address.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 space-y-1 mb-4">
|
||||
<p className="font-medium">{address.first_name} {address.last_name}</p>
|
||||
{address.company && <p>{address.company}</p>}
|
||||
<p>{address.address_1}</p>
|
||||
{address.address_2 && <p>{address.address_2}</p>}
|
||||
<p>{address.city}, {address.state} {address.postcode}</p>
|
||||
<p>{address.country}</p>
|
||||
{address.phone && <p className="pt-2">Phone: {address.phone}</p>}
|
||||
{address.email && <p>Email: {address.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(address)}
|
||||
className="font-[inherit] flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(address.id)}
|
||||
className="font-[inherit] flex items-center gap-1 text-sm text-red-600 hover:underline"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
{!address.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(address.id)}
|
||||
className="font-[inherit] flex items-center gap-1 text-sm text-gray-600 hover:underline ml-auto"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
Set Default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</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"
|
||||
/>
|
||||
</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">
|
||||
<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 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">State/Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => setFormData({ ...formData, state: 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">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"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="font-[inherit] px-4 py-2 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
customer-spa/src/pages/Account/Dashboard.tsx
Normal file
81
customer-spa/src/pages/Account/Dashboard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShoppingBag, Package, MapPin, User } from 'lucide-react';
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
Hello {user?.display_name || 'there'}!
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8">
|
||||
From your account dashboard you can view your recent orders, manage your shipping and billing addresses, and edit your password and account details.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/my-account/orders"
|
||||
className="p-6 border rounded-lg hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<ShoppingBag className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Orders</h3>
|
||||
<p className="text-sm text-gray-600">View your order history</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-account/downloads"
|
||||
className="p-6 border rounded-lg hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Downloads</h3>
|
||||
<p className="text-sm text-gray-600">Access your downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-account/addresses"
|
||||
className="p-6 border rounded-lg hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<MapPin className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Addresses</h3>
|
||||
<p className="text-sm text-gray-600">Manage your addresses</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-account/account-details"
|
||||
className="p-6 border rounded-lg hover:border-primary transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<User className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Account Details</h3>
|
||||
<p className="text-sm text-gray-600">Edit your information</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
customer-spa/src/pages/Account/Downloads.tsx
Normal file
15
customer-spa/src/pages/Account/Downloads.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
export default function Downloads() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600">No downloads available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
customer-spa/src/pages/Account/OrderDetails.tsx
Normal file
216
customer-spa/src/pages/Account/OrderDetails.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface OrderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
total: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: number;
|
||||
order_number: string;
|
||||
date: string;
|
||||
status: string;
|
||||
total: string;
|
||||
subtotal: string;
|
||||
shipping_total: string;
|
||||
tax_total: string;
|
||||
items: OrderItem[];
|
||||
billing: any;
|
||||
shipping: any;
|
||||
payment_method_title: string;
|
||||
needs_shipping: boolean;
|
||||
}
|
||||
|
||||
export default function OrderDetails() {
|
||||
const { orderId } = useParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderId) {
|
||||
loadOrder();
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
const loadOrder = async () => {
|
||||
try {
|
||||
const data = await api.get<Order>(`/account/orders/${orderId}`);
|
||||
setOrder(data);
|
||||
} catch (error) {
|
||||
console.error('Load order error:', error);
|
||||
toast.error('Failed to load order details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'completed': 'bg-green-100 text-green-800',
|
||||
'processing': 'bg-blue-100 text-blue-800',
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'on-hold': 'bg-orange-100 text-orange-800',
|
||||
'cancelled': 'bg-red-100 text-red-800',
|
||||
'refunded': 'bg-gray-100 text-gray-800',
|
||||
'failed': 'bg-red-100 text-red-800',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-600">Loading order...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div>
|
||||
<Link to="/my-account/orders" className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Orders
|
||||
</Link>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Order not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="/my-account/orders" className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Orders
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{order.status.replace('-', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mb-6">
|
||||
Placed on {formatDate(order.date)}
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="border rounded-lg mb-6">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<h2 className="text-base font-medium">Order Items</h2>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{(order.items || []).map((item) => (
|
||||
<div key={item.id} className="p-4 flex items-center gap-4">
|
||||
{item.image && (
|
||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{item.name}</h3>
|
||||
<p className="text-sm text-gray-600">Quantity: {item.quantity}</p>
|
||||
</div>
|
||||
<div className="font-semibold">{item.total}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="border rounded-lg mb-6">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<h2 className="text-base font-medium">Order Summary</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span>{order.subtotal}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Shipping</span>
|
||||
<span>{order.shipping_total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Tax</span>
|
||||
<span>{order.tax_total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-lg pt-2 border-t">
|
||||
<span>Total</span>
|
||||
<span>{order.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Addresses */}
|
||||
<div className={`grid grid-cols-1 gap-6 ${order.needs_shipping ? 'md:grid-cols-2' : ''}`}>
|
||||
<div className="border rounded-lg">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<h2 className="text-base font-medium">Billing Address</h2>
|
||||
</div>
|
||||
<div className="p-4 text-sm">
|
||||
{order.billing && (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{order.billing.first_name} {order.billing.last_name}</p>
|
||||
{order.billing.company && <p>{order.billing.company}</p>}
|
||||
<p>{order.billing.address_1}</p>
|
||||
{order.billing.address_2 && <p>{order.billing.address_2}</p>}
|
||||
<p>{order.billing.city}, {order.billing.state} {order.billing.postcode}</p>
|
||||
<p>{order.billing.country}</p>
|
||||
{order.billing.email && <p className="pt-2">Email: {order.billing.email}</p>}
|
||||
{order.billing.phone && <p>Phone: {order.billing.phone}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only show shipping address if order needs shipping (not virtual-only) */}
|
||||
{order.needs_shipping && (
|
||||
<div className="border rounded-lg">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<h2 className="text-base font-medium">Shipping Address</h2>
|
||||
</div>
|
||||
<div className="p-4 text-sm">
|
||||
{order.shipping && (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{order.shipping.first_name} {order.shipping.last_name}</p>
|
||||
{order.shipping.company && <p>{order.shipping.company}</p>}
|
||||
<p>{order.shipping.address_1}</p>
|
||||
{order.shipping.address_2 && <p>{order.shipping.address_2}</p>}
|
||||
<p>{order.shipping.city}, {order.shipping.state} {order.shipping.postcode}</p>
|
||||
<p>{order.shipping.country}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="mt-6 border rounded-lg">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<h2 className="text-base font-medium">Payment Method</h2>
|
||||
</div>
|
||||
<div className="p-4 text-sm">
|
||||
{order.payment_method_title || 'Not specified'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
customer-spa/src/pages/Account/Orders.tsx
Normal file
147
customer-spa/src/pages/Account/Orders.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Package, Eye } from 'lucide-react';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Order {
|
||||
id: number;
|
||||
order_number: string;
|
||||
date: string;
|
||||
status: string;
|
||||
total: string;
|
||||
items_count: number;
|
||||
}
|
||||
|
||||
export default function Orders() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders();
|
||||
}, [page]);
|
||||
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
const data = await api.get<any>('/account/orders', { page, per_page: 10 });
|
||||
setOrders(data.orders || []);
|
||||
setTotalPages(data.total_pages || 1);
|
||||
} catch (error) {
|
||||
console.error('Load orders error:', error);
|
||||
toast.error('Failed to load orders');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'completed': 'bg-green-100 text-green-800',
|
||||
'processing': 'bg-blue-100 text-blue-800',
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'on-hold': 'bg-orange-100 text-orange-800',
|
||||
'cancelled': 'bg-red-100 text-red-800',
|
||||
'refunded': 'bg-gray-100 text-gray-800',
|
||||
'failed': 'bg-red-100 text-red-800',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-600">Loading orders...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Orders</h1>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<Package className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No orders yet</p>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="inline-block px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Browse Products
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Orders</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
{orders.map((order) => (
|
||||
<div key={order.id} className="border rounded-lg p-4 hover:border-primary transition-colors">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">Order #{order.order_number}</h3>
|
||||
<p className="text-sm text-gray-600">{formatDate(order.date)}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{order.status.replace('-', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{order.items_count} {order.items_count === 1 ? 'item' : 'items'}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold text-lg">{order.total}</span>
|
||||
<Link
|
||||
to={`/my-account/orders/${order.id}`}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-primary text-primary rounded-lg hover:bg-primary hover:text-white transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="font-[inherit] px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="font-[inherit] px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
customer-spa/src/pages/Account/components/AccountLayout.tsx
Normal file
121
customer-spa/src/pages/Account/components/AccountLayout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, User, LogOut } from 'lucide-react';
|
||||
|
||||
interface AccountLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
const location = useLocation();
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
window.location.href = '/wp-login.php?action=logout';
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/my-account') {
|
||||
return location.pathname === '/my-account';
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
// Sidebar Navigation
|
||||
const SidebarNav = () => (
|
||||
<aside className="bg-white rounded-lg border p-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 pb-4 border-b">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{user?.display_name || 'User'}</p>
|
||||
<p className="text-sm text-gray-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
|
||||
isActive(item.path)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full font-[inherit] flex items-center gap-3 px-4 py-2.5 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium">Logout</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
// Tab Navigation (Mobile)
|
||||
const TabNav = () => (
|
||||
<div className="bg-white rounded-lg border mb-6 lg:hidden">
|
||||
<nav className="flex overflow-x-auto">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${
|
||||
isActive(item.path)
|
||||
? 'border-primary text-primary font-medium'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Responsive layout: Tabs on mobile, Sidebar on desktop
|
||||
return (
|
||||
<div className="py-8">
|
||||
{/* Mobile: Tab Navigation */}
|
||||
<TabNav />
|
||||
|
||||
{/* Desktop: Sidebar + Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="hidden lg:block lg:col-span-1">
|
||||
<SidebarNav />
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link } from 'react-router-dom';
|
||||
|
||||
function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">My Account</h2>
|
||||
<p className="text-muted-foreground">Welcome to your account dashboard.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Orders() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Orders</h2>
|
||||
<p className="text-muted-foreground">Your order history will appear here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Profile</h2>
|
||||
<p className="text-muted-foreground">Edit your profile information.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { AccountLayout } from './components/AccountLayout';
|
||||
import Dashboard from './Dashboard';
|
||||
import Orders from './Orders';
|
||||
import OrderDetails from './OrderDetails';
|
||||
import Downloads from './Downloads';
|
||||
import Addresses from './Addresses';
|
||||
import AccountDetails from './AccountDetails';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user?.isLoggedIn) {
|
||||
window.location.href = '/wp-login.php?redirect_to=' + encodeURIComponent(window.location.href);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="md:col-span-1">
|
||||
<nav className="space-y-2">
|
||||
<Link
|
||||
to="/account"
|
||||
className="block px-4 py-2 rounded-lg hover:bg-accent"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/account/orders"
|
||||
className="block px-4 py-2 rounded-lg hover:bg-accent"
|
||||
>
|
||||
Orders
|
||||
</Link>
|
||||
<Link
|
||||
to="/account/profile"
|
||||
className="block px-4 py-2 rounded-lg hover:bg-accent"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="md:col-span-3">
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="profile" element={<Profile />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Container>
|
||||
<AccountLayout>
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetails />} />
|
||||
<Route path="downloads" element={<Downloads />} />
|
||||
<Route path="addresses" element={<Addresses />} />
|
||||
<Route path="account-details" element={<AccountDetails />} />
|
||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||
</Routes>
|
||||
</AccountLayout>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function Cart() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
className="font-[inherit] p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -141,7 +141,7 @@ export default function Cart() {
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
className="font-[inherit] p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -152,7 +152,7 @@ export default function Cart() {
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.key)}
|
||||
className="text-red-600 hover:text-red-700 p-2"
|
||||
className="font-[inherit] text-red-600 hover:text-red-700 p-2"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
@@ -5,9 +5,29 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ArrowLeft, ShoppingBag } from 'lucide-react';
|
||||
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { AddressSelector } from '@/components/AddressSelector';
|
||||
|
||||
interface SavedAddress {
|
||||
id: number;
|
||||
label: string;
|
||||
type: 'billing' | 'shipping' | 'both';
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company?: string;
|
||||
address_1: string;
|
||||
address_2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function Checkout() {
|
||||
const navigate = useNavigate();
|
||||
@@ -54,10 +74,95 @@ export default function Checkout() {
|
||||
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
||||
const [orderNotes, setOrderNotes] = useState('');
|
||||
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
|
||||
|
||||
// Saved addresses
|
||||
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
|
||||
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<number | null>(null);
|
||||
const [selectedShippingAddressId, setSelectedShippingAddressId] = useState<number | null>(null);
|
||||
const [loadingAddresses, setLoadingAddresses] = useState(true);
|
||||
const [showBillingModal, setShowBillingModal] = useState(false);
|
||||
const [showShippingModal, setShowShippingModal] = useState(false);
|
||||
const [showBillingForm, setShowBillingForm] = useState(true);
|
||||
const [showShippingForm, setShowShippingForm] = useState(true);
|
||||
|
||||
// Load saved addresses
|
||||
useEffect(() => {
|
||||
const loadAddresses = async () => {
|
||||
if (!user?.isLoggedIn) {
|
||||
setLoadingAddresses(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const addresses = await api.get<SavedAddress[]>('/account/addresses');
|
||||
setSavedAddresses(addresses);
|
||||
|
||||
// Auto-select default addresses
|
||||
const defaultBilling = addresses.find(a => a.is_default && (a.type === 'billing' || a.type === 'both'));
|
||||
const defaultShipping = addresses.find(a => a.is_default && (a.type === 'shipping' || a.type === 'both'));
|
||||
|
||||
if (defaultBilling) {
|
||||
setSelectedBillingAddressId(defaultBilling.id);
|
||||
fillBillingFromAddress(defaultBilling);
|
||||
setShowBillingForm(false); // Hide form when default address is auto-selected
|
||||
}
|
||||
if (defaultShipping && !isVirtualOnly) {
|
||||
setSelectedShippingAddressId(defaultShipping.id);
|
||||
fillShippingFromAddress(defaultShipping);
|
||||
setShowShippingForm(false); // Hide form when default address is auto-selected
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load addresses:', error);
|
||||
} finally {
|
||||
setLoadingAddresses(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAddresses();
|
||||
}, [user, isVirtualOnly]);
|
||||
|
||||
// Helper functions to fill forms from saved addresses
|
||||
const fillBillingFromAddress = (address: SavedAddress) => {
|
||||
setBillingData({
|
||||
firstName: address.first_name,
|
||||
lastName: address.last_name,
|
||||
email: address.email || billingData.email,
|
||||
phone: address.phone || billingData.phone,
|
||||
address: address.address_1,
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
country: address.country,
|
||||
});
|
||||
};
|
||||
|
||||
const fillShippingFromAddress = (address: SavedAddress) => {
|
||||
setShippingData({
|
||||
firstName: address.first_name,
|
||||
lastName: address.last_name,
|
||||
address: address.address_1,
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
country: address.country,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectBillingAddress = (address: SavedAddress) => {
|
||||
setSelectedBillingAddressId(address.id);
|
||||
fillBillingFromAddress(address);
|
||||
setShowBillingForm(false); // Hide form when address is selected
|
||||
};
|
||||
|
||||
const handleSelectShippingAddress = (address: SavedAddress) => {
|
||||
setSelectedShippingAddressId(address.id);
|
||||
fillShippingFromAddress(address);
|
||||
setShowShippingForm(false); // Hide form when address is selected
|
||||
};
|
||||
|
||||
// Auto-fill form with user data if logged in
|
||||
useEffect(() => {
|
||||
if (user?.isLoggedIn && user?.billing) {
|
||||
if (user?.isLoggedIn && user?.billing && savedAddresses.length === 0) {
|
||||
setBillingData({
|
||||
firstName: user.billing.first_name || '',
|
||||
lastName: user.billing.last_name || '',
|
||||
@@ -183,7 +288,75 @@ export default function Checkout() {
|
||||
<div className={`grid gap-8 ${layout.style === 'single-column' ? 'grid-cols-1' : layout.order_summary === 'top' ? 'grid-cols-1' : 'lg:grid-cols-3'}`}>
|
||||
{/* Billing & Shipping Forms */}
|
||||
<div className={`space-y-6 ${layout.style === 'single-column' || layout.order_summary === 'top' ? '' : 'lg:col-span-2'}`}>
|
||||
{/* Billing Details */}
|
||||
{/* Selected Billing Address Summary */}
|
||||
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'billing' || a.type === 'both') && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" />
|
||||
Billing Address
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBillingModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Change Address
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedBillingAddressId ? (
|
||||
(() => {
|
||||
const selected = savedAddresses.find(a => a.id === selectedBillingAddressId);
|
||||
return selected ? (
|
||||
<div>
|
||||
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="font-semibold">{selected.label}</p>
|
||||
{selected.is_default && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
|
||||
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
|
||||
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
|
||||
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
|
||||
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
|
||||
<p className="text-sm text-gray-600">{selected.country}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowBillingForm(true)}
|
||||
className="mt-3 text-primary hover:text-primary"
|
||||
>
|
||||
Use a different address
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
})()
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No address selected</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Address Modal */}
|
||||
<AddressSelector
|
||||
isOpen={showBillingModal}
|
||||
onClose={() => setShowBillingModal(false)}
|
||||
addresses={savedAddresses}
|
||||
selectedAddressId={selectedBillingAddressId}
|
||||
onSelectAddress={handleSelectBillingAddress}
|
||||
type="billing"
|
||||
/>
|
||||
|
||||
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
|
||||
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -285,6 +458,7 @@ export default function Checkout() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ship to Different Address - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
@@ -300,7 +474,77 @@ export default function Checkout() {
|
||||
</label>
|
||||
|
||||
{shipToDifferentAddress && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<>
|
||||
{/* Selected Shipping Address Summary */}
|
||||
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Shipping Address
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowShippingModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Change Address
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedShippingAddressId ? (
|
||||
(() => {
|
||||
const selected = savedAddresses.find(a => a.id === selectedShippingAddressId);
|
||||
return selected ? (
|
||||
<div>
|
||||
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="font-semibold">{selected.label}</p>
|
||||
{selected.is_default && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
|
||||
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
|
||||
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
|
||||
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
|
||||
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
|
||||
<p className="text-sm text-gray-600">{selected.country}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowShippingForm(true)}
|
||||
className="mt-3 text-primary hover:text-primary"
|
||||
>
|
||||
Use a different address
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
})()
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No address selected</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping Address Modal */}
|
||||
<AddressSelector
|
||||
isOpen={showShippingModal}
|
||||
onClose={() => setShowShippingModal(false)}
|
||||
addresses={savedAddresses}
|
||||
selectedAddressId={selectedShippingAddressId}
|
||||
onSelectAddress={handleSelectShippingAddress}
|
||||
type="shipping"
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
@@ -372,6 +616,8 @@ export default function Checkout() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,21 +23,56 @@ export default function Shop() {
|
||||
const [sortBy, setSortBy] = useState('');
|
||||
const { addItem } = useCartStore();
|
||||
|
||||
// Map grid columns setting to Tailwind classes
|
||||
const gridColsClass = {
|
||||
'2': 'grid-cols-1 sm:grid-cols-2',
|
||||
'3': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
'5': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
||||
'6': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6',
|
||||
}[shopLayout.grid_columns] || 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
|
||||
// Map grid columns setting to Tailwind classes (responsive)
|
||||
const gridCols = typeof shopLayout.grid_columns === 'object'
|
||||
? shopLayout.grid_columns
|
||||
: { mobile: '2', tablet: '3', desktop: '4' };
|
||||
|
||||
// Masonry column classes (CSS columns)
|
||||
const masonryColsClass = {
|
||||
'2': 'columns-1 sm:columns-2',
|
||||
'3': 'columns-1 sm:columns-2 lg:columns-3',
|
||||
'4': 'columns-1 sm:columns-2 lg:columns-3 xl:columns-4',
|
||||
}[shopLayout.grid_columns] || 'columns-1 sm:columns-2 lg:columns-3';
|
||||
// Map to actual Tailwind classes (can't use template literals due to purging)
|
||||
const mobileClass = {
|
||||
'1': 'grid-cols-1',
|
||||
'2': 'grid-cols-2',
|
||||
'3': 'grid-cols-3',
|
||||
}[gridCols.mobile] || 'grid-cols-2';
|
||||
|
||||
const tabletClass = {
|
||||
'2': 'md:grid-cols-2',
|
||||
'3': 'md:grid-cols-3',
|
||||
'4': 'md:grid-cols-4',
|
||||
}[gridCols.tablet] || 'md:grid-cols-3';
|
||||
|
||||
const desktopClass = {
|
||||
'2': 'lg:grid-cols-2',
|
||||
'3': 'lg:grid-cols-3',
|
||||
'4': 'lg:grid-cols-4',
|
||||
'5': 'lg:grid-cols-5',
|
||||
'6': 'lg:grid-cols-6',
|
||||
}[gridCols.desktop] || 'lg:grid-cols-4';
|
||||
|
||||
const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`;
|
||||
|
||||
// Masonry column classes
|
||||
const masonryMobileClass = {
|
||||
'1': 'columns-1',
|
||||
'2': 'columns-2',
|
||||
'3': 'columns-3',
|
||||
}[gridCols.mobile] || 'columns-2';
|
||||
|
||||
const masonryTabletClass = {
|
||||
'2': 'md:columns-2',
|
||||
'3': 'md:columns-3',
|
||||
'4': 'md:columns-4',
|
||||
}[gridCols.tablet] || 'md:columns-3';
|
||||
|
||||
const masonryDesktopClass = {
|
||||
'2': 'lg:columns-2',
|
||||
'3': 'lg:columns-3',
|
||||
'4': 'lg:columns-4',
|
||||
'5': 'lg:columns-5',
|
||||
'6': 'lg:columns-6',
|
||||
}[gridCols.desktop] || 'lg:columns-4';
|
||||
|
||||
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
||||
|
||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||
|
||||
@@ -114,7 +149,7 @@ export default function Shop() {
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
|
||||
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user