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:
Dwindi Ramadhana
2025-12-26 01:16:11 +07:00
parent 9ac09582d2
commit 100f9cce55
27 changed files with 2492 additions and 205 deletions

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { X, Search, MapPin, Check } from 'lucide-react';
import { Button } from './ui/button';
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;
}
interface AddressSelectorProps {
isOpen: boolean;
onClose: () => void;
addresses: Address[];
selectedAddressId: number | null;
onSelectAddress: (address: Address) => void;
type: 'billing' | 'shipping';
}
export function AddressSelector({
isOpen,
onClose,
addresses,
selectedAddressId,
onSelectAddress,
type,
}: AddressSelectorProps) {
const [searchQuery, setSearchQuery] = useState('');
if (!isOpen) return null;
// Filter addresses by type and search query
const filteredAddresses = addresses
.filter(a => a.type === type || a.type === 'both')
.filter(a => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
a.label.toLowerCase().includes(query) ||
a.first_name.toLowerCase().includes(query) ||
a.last_name.toLowerCase().includes(query) ||
a.address_1.toLowerCase().includes(query) ||
a.city.toLowerCase().includes(query)
);
});
const handleSelect = (address: Address) => {
onSelectAddress(address);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] m-4 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-bold">
Select {type === 'billing' ? 'Billing' : 'Shipping'} Address
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Search */}
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search by name, address, or city..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Address List */}
<div className="flex-1 overflow-y-auto p-4">
{filteredAddresses.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<MapPin className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No addresses found</p>
</div>
) : (
<div className="space-y-3">
{filteredAddresses.map((address) => (
<div
key={address.id}
onClick={() => handleSelect(address)}
className={`relative border-2 rounded-lg p-4 cursor-pointer transition-all ${
selectedAddressId === address.id
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-primary/50'
}`}
>
{selectedAddressId === address.id && (
<div className="absolute top-4 right-4 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
</div>
)}
<div className="pr-10">
{/* Label */}
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold text-base">{address.label}</p>
{address.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">
Default
</span>
)}
</div>
{/* Name & Phone */}
<p className="text-sm font-medium text-gray-900">
{address.first_name} {address.last_name}
</p>
{address.phone && (
<p className="text-sm text-gray-600">{address.phone}</p>
)}
{/* Company */}
{address.company && (
<p className="text-sm text-gray-600">{address.company}</p>
)}
{/* Address */}
<p className="text-sm text-gray-600 mt-2">
{address.address_1}
{address.address_2 && `, ${address.address_2}`}
</p>
<p className="text-sm text-gray-600">
{address.city}, {address.state} {address.postcode}
</p>
<p className="text-sm text-gray-600">{address.country}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t bg-gray-50">
<Button
variant="outline"
onClick={onClose}
className="w-full"
>
Cancel
</Button>
</div>
</div>
</div>
);
}

View File

@@ -138,7 +138,7 @@ export default function Footer() {
/>
<button
type="submit"
className="absolute right-1.5 top-1.5 p-1.5 bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors"
className="font-[inherit] absolute right-1.5 top-1.5 p-1.5 bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors"
>
<Mail className="h-4 w-4" />
</button>

View File

@@ -60,7 +60,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="font-[inherit] w-full px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Subscribing...' : 'Subscribe'}
</button>

View File

@@ -17,6 +17,7 @@ interface ProductCardProps {
image?: string;
on_sale?: boolean;
stock_status?: string;
type?: string;
};
onAddToCart?: (product: any) => void;
}
@@ -32,9 +33,18 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
'landscape': 'aspect-[4/3]',
}[layout.aspect_ratio] || 'aspect-square';
const isVariable = product.type === 'variable';
const handleAddToCart = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Variable products need to go to product page for attribute selection
if (isVariable) {
window.location.href = `/product/${product.slug}`;
return;
}
onAddToCart?.(product);
};
@@ -122,7 +132,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{/* Quick Actions */}
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 bg-white rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center">
<button className="font-[inherit] p-2 bg-white rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center">
<Heart className="w-4 h-4 block" />
</button>
</div>
@@ -137,7 +147,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'}
>
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
{product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
</Button>
</div>
)}
@@ -145,7 +155,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{/* Content */}
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
<h3 className="font-semibold text-gray-900 mb-2 line-clamp-2 group-hover:text-primary transition-colors">
<h3 className="text-sm font-medium text-gray-900 mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
{product.name}
</h3>
@@ -153,15 +163,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
{product.on_sale && product.regular_price ? (
<>
<span className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>
<span className="text-base font-bold" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.sale_price || product.price)}
</span>
<span className="text-sm text-gray-500 line-through">
<span className="text-xs text-gray-500 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-lg font-bold text-gray-900">
<span className="text-base font-bold text-gray-900">
{formatPrice(product.price)}
</span>
)}
@@ -176,7 +186,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'}
>
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
{product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
</Button>
)}
</div>
@@ -224,7 +234,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'}
>
{addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
{product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
</Button>
</div>
)}
@@ -232,7 +242,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{/* Content */}
<div className="text-center">
<h3 className="font-medium text-gray-900 mb-2 group-hover:text-primary transition-colors">
<h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug group-hover:text-primary transition-colors">
{product.name}
</h3>
@@ -240,15 +250,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="flex items-center justify-center gap-2 mb-3">
{product.on_sale && product.regular_price ? (
<>
<span className="font-semibold" style={{ color: 'var(--color-primary)' }}>
<span className="text-base font-bold" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.sale_price || product.price)}
</span>
<span className="text-sm text-gray-400 line-through">
<span className="text-xs text-gray-400 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="font-semibold text-gray-900">
<span className="text-base font-bold text-gray-900">
{formatPrice(product.price)}
</span>
)}
@@ -320,7 +330,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{/* Content */}
<div className="text-center font-serif">
<h3 className="text-lg font-medium text-gray-900 mb-3 tracking-wide group-hover:text-primary transition-colors">
<h3 className="text-sm font-medium text-gray-900 mb-3 tracking-wide leading-snug group-hover:text-primary transition-colors">
{product.name}
</h3>
@@ -328,15 +338,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="flex items-center justify-center gap-3 mb-4">
{product.on_sale && product.regular_price ? (
<>
<span className="text-xl font-medium" style={{ color: 'var(--color-primary)' }}>
<span className="text-lg font-semibold" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.sale_price || product.price)}
</span>
<span className="text-gray-400 line-through">
<span className="text-sm text-gray-400 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-xl font-medium text-gray-900">
<span className="text-lg font-semibold text-gray-900">
{formatPrice(product.price)}
</span>
)}
@@ -349,7 +359,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
className="w-full font-serif tracking-wider"
disabled={product.stock_status === 'outofstock'}
>
{product.stock_status === 'outofstock' ? 'OUT OF STOCK' : 'ADD TO CART'}
{product.stock_status === 'outofstock' ? 'OUT OF STOCK' : isVariable ? 'SELECT OPTIONS' : 'ADD TO CART'}
</Button>
</div>
</div>
@@ -376,12 +386,12 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</div>
<div className="p-4 text-center">
<h3 className="font-semibold text-gray-900 mb-2">{product.name}</h3>
<div className="text-xl font-bold mb-3" style={{ color: 'var(--color-primary)' }}>
<h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug">{product.name}</h3>
<div className="text-lg font-bold mb-3" style={{ color: 'var(--color-primary)' }}>
{formatPrice(product.price)}
</div>
<Button onClick={handleAddToCart} className="w-full" size="lg">
Buy Now
{isVariable ? 'Select Options' : 'Buy Now'}
</Button>
</div>
</div>

View File

@@ -82,7 +82,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
className="flex-1 outline-none text-lg"
autoFocus
/>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
<button onClick={onClose} className="font-[inherit] p-2 hover:bg-gray-100 rounded-lg">
<X className="h-5 w-5 text-gray-600" />
</button>
</div>

View File

@@ -112,51 +112,45 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{headerSettings.elements.search && (
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<Search className="h-5 w-5 text-gray-600" />
</button>
)}
{/* Account */}
{headerSettings.elements.account && (user?.isLoggedIn ? (
<Link to="/my-account" className="no-underline">
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors">
<User className="h-5 w-5 text-gray-600" />
<span className="hidden lg:block text-sm font-medium text-gray-700">Account</span>
</button>
<Link to="/my-account" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<User className="h-5 w-5" />
<span className="hidden lg:block">Account</span>
</Link>
) : (
<a href="/wp-login.php" className="no-underline">
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors">
<User className="h-5 w-5 text-gray-600" />
<span className="hidden lg:block text-sm font-medium text-gray-700">Account</span>
</button>
<a href="/wp-login.php" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<User className="h-5 w-5" />
<span className="hidden lg:block">Account</span>
</a>
))}
{/* Cart */}
{headerSettings.elements.cart && (
<Link to="/cart" className="no-underline">
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors relative">
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<div className="relative">
<ShoppingCart className="h-5 w-5 text-gray-600" />
<ShoppingCart className="h-5 w-5" />
{itemCount > 0 && (
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
{itemCount}
</span>
)}
</div>
<span className="hidden lg:block text-sm font-medium text-gray-700">
<span className="hidden lg:block">
Cart ({itemCount})
</span>
</button>
</Link>
)}
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
{(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && (
<button
className="md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
@@ -186,7 +180,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
<div className="fixed top-0 left-0 h-full w-64 bg-white shadow-xl z-50 md:hidden transform transition-transform">
<div className="p-4 border-b flex justify-between items-center">
<span className="font-semibold">Menu</span>
<button onClick={() => setMobileMenuOpen(false)}>
<button onClick={() => setMobileMenuOpen(false)} className="font-[inherit]">
<X className="h-5 w-5 text-gray-600" />
</button>
</div>
@@ -218,7 +212,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{headerSettings.elements.search && (
<button
onClick={() => setSearchOpen(true)}
className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900"
className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
<Search className="h-5 w-5" />
<span>Search</span>
@@ -395,7 +389,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
{headerSettings.elements.search && (
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
<Search className="h-4 w-4" />
</button>
@@ -523,7 +517,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
{headerSettings.elements.search && (
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors"
className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors"
>
<Search className="h-4 w-4" />
</button>
@@ -547,7 +541,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
{/* Mobile Menu Toggle */}
<button
className="md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

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