diff --git a/MY_ACCOUNT_PLAN.md b/MY_ACCOUNT_PLAN.md new file mode 100644 index 0000000..28c3e44 --- /dev/null +++ b/MY_ACCOUNT_PLAN.md @@ -0,0 +1,312 @@ +# My Account Settings & Frontend - Comprehensive Plan + +## Overview +Complete implementation plan for My Account functionality including admin settings and customer-facing frontend. + +--- + +## 1. ADMIN SETTINGS (`admin-spa/src/routes/Appearance/Account.tsx`) + +### Settings Structure + +#### **A. Layout Settings** +- **Dashboard Layout** + - `style`: 'sidebar' | 'tabs' | 'minimal' + - `sidebar_position`: 'left' | 'right' (for sidebar style) + - `mobile_menu`: 'bottom-nav' | 'hamburger' | 'accordion' + +#### **B. Menu Items Control** +Enable/disable and reorder menu items: +- Dashboard (overview) +- Orders +- Downloads +- Addresses (Billing & Shipping) +- Account Details (profile edit) +- Payment Methods +- Wishlist (if enabled) +- Logout + +#### **C. Dashboard Widgets** +Configurable widgets for dashboard overview: +- Recent Orders (show last N orders) +- Account Stats (total orders, total spent) +- Quick Actions (reorder, track order) +- Recommended Products + +#### **D. Visual Settings** +- Avatar display: show/hide +- Welcome message customization +- Card style: 'card' | 'minimal' | 'bordered' +- Color scheme for active states + +--- + +## 2. FRONTEND IMPLEMENTATION (`customer-spa/src/pages/Account/`) + +### File Structure +``` +customer-spa/src/pages/Account/ +├── index.tsx # Main router +├── Dashboard.tsx # Overview/home +├── Orders.tsx # Order history +├── OrderDetails.tsx # Single order view +├── Downloads.tsx # Downloadable products +├── Addresses.tsx # Billing & shipping addresses +├── AddressEdit.tsx # Edit address form +├── AccountDetails.tsx # Profile edit +├── PaymentMethods.tsx # Saved payment methods +└── components/ + ├── AccountLayout.tsx # Layout wrapper + ├── AccountSidebar.tsx # Navigation sidebar + ├── AccountTabs.tsx # Tab navigation + ├── OrderCard.tsx # Order list item + └── DashboardWidget.tsx # Dashboard widgets +``` + +### Features by Page + +#### **Dashboard** +- Welcome message with user name +- Account statistics cards +- Recent orders (3-5 latest) +- Quick action buttons +- Recommended/recently viewed products + +#### **Orders** +- Filterable order list (all, pending, completed, cancelled) +- Search by order number +- Pagination +- Order cards showing: + - Order number, date, status + - Total amount + - Items count + - Quick actions (view, reorder, track) + +#### **Order Details** +- Full order information +- Order status timeline +- Items list with images +- Billing/shipping addresses +- Payment method +- Download invoice button +- Reorder button +- Track shipment (if available) + +#### **Downloads** +- List of downloadable products +- Download buttons +- Expiry dates +- Download count/limits + +#### **Addresses** +- Billing address card +- Shipping address card +- Edit/delete buttons +- Add new address +- Set as default + +#### **Account Details** +- Edit profile form: + - First name, last name + - Display name + - Email + - Phone (optional) + - Avatar upload (optional) +- Change password section +- Email preferences + +#### **Payment Methods** +- Saved payment methods list +- Add new payment method +- Set default +- Delete payment method +- Secure display (last 4 digits) + +--- + +## 3. API ENDPOINTS NEEDED + +### Customer Endpoints +```php +// Account +GET /woonoow/v1/account/dashboard +GET /woonoow/v1/account/details +PUT /woonoow/v1/account/details + +// Orders +GET /woonoow/v1/account/orders +GET /woonoow/v1/account/orders/{id} +POST /woonoow/v1/account/orders/{id}/reorder + +// Downloads +GET /woonoow/v1/account/downloads + +// Addresses +GET /woonoow/v1/account/addresses +GET /woonoow/v1/account/addresses/{type} // billing or shipping +PUT /woonoow/v1/account/addresses/{type} +DELETE /woonoow/v1/account/addresses/{type} + +// Payment Methods +GET /woonoow/v1/account/payment-methods +POST /woonoow/v1/account/payment-methods +DELETE /woonoow/v1/account/payment-methods/{id} +PUT /woonoow/v1/account/payment-methods/{id}/default +``` + +### Admin Endpoints +```php +// Settings +GET /woonoow/v1/appearance/pages/account +POST /woonoow/v1/appearance/pages/account +``` + +--- + +## 4. BACKEND IMPLEMENTATION + +### Controllers Needed +``` +includes/Api/ +├── AccountController.php # Account details, dashboard +├── OrdersController.php # Order management (already exists?) +├── DownloadsController.php # Downloads management +├── AddressesController.php # Address CRUD +└── PaymentMethodsController.php # Payment methods +``` + +### Database Considerations +- Use WooCommerce native tables +- Customer meta for preferences +- Order data from `wp_wc_orders` or `wp_posts` +- Downloads from WooCommerce downloads system + +--- + +## 5. SETTINGS SCHEMA + +### Default Settings +```json +{ + "pages": { + "account": { + "layout": { + "style": "sidebar", + "sidebar_position": "left", + "mobile_menu": "bottom-nav", + "card_style": "card" + }, + "menu_items": [ + { "id": "dashboard", "label": "Dashboard", "enabled": true, "order": 1 }, + { "id": "orders", "label": "Orders", "enabled": true, "order": 2 }, + { "id": "downloads", "label": "Downloads", "enabled": true, "order": 3 }, + { "id": "addresses", "label": "Addresses", "enabled": true, "order": 4 }, + { "id": "account-details", "label": "Account Details", "enabled": true, "order": 5 }, + { "id": "payment-methods", "label": "Payment Methods", "enabled": true, "order": 6 }, + { "id": "logout", "label": "Logout", "enabled": true, "order": 7 } + ], + "dashboard_widgets": { + "recent_orders": { "enabled": true, "count": 5 }, + "account_stats": { "enabled": true }, + "quick_actions": { "enabled": true }, + "recommended_products": { "enabled": false } + }, + "elements": { + "avatar": true, + "welcome_message": true, + "breadcrumbs": true + }, + "labels": { + "welcome_message": "Welcome back, {name}!", + "dashboard_title": "My Account", + "no_orders_message": "You haven't placed any orders yet." + } + } + } +} +``` + +--- + +## 6. IMPLEMENTATION PHASES + +### Phase 1: Foundation (Priority: HIGH) +1. Create admin settings page (`Account.tsx`) +2. Create backend controller (`AppearanceController.php` - add account section) +3. Create API endpoints for settings +4. Create basic account layout structure + +### Phase 2: Core Pages (Priority: HIGH) +1. Dashboard page +2. Orders list page +3. Order details page +4. Account details/profile edit + +### Phase 3: Additional Features (Priority: MEDIUM) +1. Addresses management +2. Downloads page +3. Payment methods + +### Phase 4: Polish (Priority: LOW) +1. Dashboard widgets +2. Recommended products +3. Advanced filtering/search +4. Mobile optimizations + +--- + +## 7. MOBILE CONSIDERATIONS + +- Bottom navigation for mobile (like checkout) +- Collapsible sidebar on tablet +- Touch-friendly buttons +- Swipe gestures for order cards +- Responsive tables for order details + +--- + +## 8. SECURITY CONSIDERATIONS + +- Verify user authentication on all endpoints +- Check order ownership before displaying +- Sanitize all inputs +- Validate email changes +- Secure password change flow +- Rate limiting on sensitive operations + +--- + +## 9. UX ENHANCEMENTS + +- Loading states for all async operations +- Empty states with helpful CTAs +- Success/error toast notifications +- Confirmation dialogs for destructive actions +- Breadcrumb navigation +- Back buttons where appropriate +- Skeleton loaders + +--- + +## 10. INTEGRATION POINTS + +### With Existing Features +- Cart system (reorder functionality) +- Product pages (from order history) +- Checkout (saved addresses, payment methods) +- Email system (order notifications) + +### With WooCommerce +- Native order system +- Customer data +- Download permissions +- Payment gateways + +--- + +## NEXT STEPS + +1. **Immediate**: Create admin settings page structure +2. **Then**: Implement basic API endpoints +3. **Then**: Build frontend layout and routing +4. **Finally**: Implement individual pages one by one diff --git a/admin-spa/src/routes/Appearance/Header.tsx b/admin-spa/src/routes/Appearance/Header.tsx index bee6073..264ca10 100644 --- a/admin-spa/src/routes/Appearance/Header.tsx +++ b/admin-spa/src/routes/Appearance/Header.tsx @@ -63,10 +63,10 @@ export default function AppearanceHeader() { style, sticky, height, - mobile_menu: mobileMenu, - mobile_logo: mobileLogo, - logo_width: logoWidth, - logo_height: logoHeight, + mobileMenu, + mobileLogo, + logoWidth, + logoHeight, elements, }); toast.success('Header settings saved successfully'); diff --git a/admin-spa/src/routes/Appearance/Shop.tsx b/admin-spa/src/routes/Appearance/Shop.tsx index 865f20b..6044c3b 100644 --- a/admin-spa/src/routes/Appearance/Shop.tsx +++ b/admin-spa/src/routes/Appearance/Shop.tsx @@ -11,7 +11,11 @@ import { api } from '@/lib/api'; export default function AppearanceShop() { const [loading, setLoading] = useState(true); - const [gridColumns, setGridColumns] = useState('3'); + const [gridColumns, setGridColumns] = useState({ + mobile: '2', + tablet: '3', + desktop: '4' + }); const [gridStyle, setGridStyle] = useState('standard'); const [cardStyle, setCardStyle] = useState('card'); const [aspectRatio, setAspectRatio] = useState('square'); @@ -37,7 +41,11 @@ export default function AppearanceShop() { const shop = response.data?.pages?.shop; if (shop) { - setGridColumns(shop.layout?.grid_columns || '3'); + setGridColumns(shop.layout?.grid_columns || { + mobile: '2', + tablet: '3', + desktop: '4' + }); setGridStyle(shop.layout?.grid_style || 'standard'); setCardStyle(shop.layout?.card_style || 'card'); setAspectRatio(shop.layout?.aspect_ratio || 'square'); @@ -110,17 +118,55 @@ export default function AppearanceShop() { title="Layout" description="Configure shop page layout and product display" > - - + +
+
+ + +

<768px

+
+ +
+ + +

768-1024px

+
+ +
+ + +

>1024px

+
+
diff --git a/admin-spa/src/routes/Settings/Customers.tsx b/admin-spa/src/routes/Settings/Customers.tsx index 6290c98..5d492bc 100644 --- a/admin-spa/src/routes/Settings/Customers.tsx +++ b/admin-spa/src/routes/Settings/Customers.tsx @@ -12,6 +12,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency'; interface CustomerSettings { auto_register_members: boolean; + multiple_addresses_enabled: boolean; vip_min_spent: number; vip_min_orders: number; vip_timeframe: 'all' | '30' | '90' | '365'; @@ -22,6 +23,7 @@ interface CustomerSettings { export default function CustomersSettings() { const [settings, setSettings] = useState({ auto_register_members: false, + multiple_addresses_enabled: true, vip_min_spent: 1000, vip_min_orders: 10, vip_timeframe: 'all', @@ -119,13 +121,23 @@ export default function CustomersSettings() { title={__('General')} description={__('General customer settings')} > - setSettings({ ...settings, auto_register_members: checked })} - /> +
+ setSettings({ ...settings, auto_register_members: checked })} + /> + + setSettings({ ...settings, multiple_addresses_enabled: checked })} + /> +
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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ Select {type === 'billing' ? 'Billing' : 'Shipping'} Address +

+ +
+ + {/* Search */} +
+
+ + 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" + /> +
+
+ + {/* Address List */} +
+ {filteredAddresses.length === 0 ? ( +
+ +

No addresses found

+
+ ) : ( +
+ {filteredAddresses.map((address) => ( +
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 && ( +
+ +
+ )} + +
+ {/* Label */} +
+

{address.label}

+ {address.is_default && ( + + Default + + )} +
+ + {/* Name & Phone */} +

+ {address.first_name} {address.last_name} +

+ {address.phone && ( +

{address.phone}

+ )} + + {/* Company */} + {address.company && ( +

{address.company}

+ )} + + {/* Address */} +

+ {address.address_1} + {address.address_2 && `, ${address.address_2}`} +

+

+ {address.city}, {address.state} {address.postcode} +

+

{address.country}

+
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/customer-spa/src/components/Layout/Footer.tsx b/customer-spa/src/components/Layout/Footer.tsx index 945cc0e..881e6b4 100644 --- a/customer-spa/src/components/Layout/Footer.tsx +++ b/customer-spa/src/components/Layout/Footer.tsx @@ -138,7 +138,7 @@ export default function Footer() { /> diff --git a/customer-spa/src/components/NewsletterForm.tsx b/customer-spa/src/components/NewsletterForm.tsx index cf54122..28c7bfa 100644 --- a/customer-spa/src/components/NewsletterForm.tsx +++ b/customer-spa/src/components/NewsletterForm.tsx @@ -60,7 +60,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) { diff --git a/customer-spa/src/components/ProductCard.tsx b/customer-spa/src/components/ProductCard.tsx index 1ab88b3..2d9f1d4 100644 --- a/customer-spa/src/components/ProductCard.tsx +++ b/customer-spa/src/components/ProductCard.tsx @@ -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 */}
-
@@ -137,7 +147,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { disabled={product.stock_status === 'outofstock'} > {!isTextOnly && addToCart.show_icon && } - {product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'} + {product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
)} @@ -145,7 +155,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { {/* Content */}
-

+

{product.name}

@@ -153,15 +163,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{product.on_sale && product.regular_price ? ( <> - + {formatPrice(product.sale_price || product.price)} - + {formatPrice(product.regular_price)} ) : ( - + {formatPrice(product.price)} )} @@ -176,7 +186,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { disabled={product.stock_status === 'outofstock'} > {!isTextOnly && addToCart.show_icon && } - {product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'} + {product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'} )}
@@ -224,7 +234,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { disabled={product.stock_status === 'outofstock'} > {addToCart.show_icon && } - {product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'} + {product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
)} @@ -232,7 +242,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { {/* Content */}
-

+

{product.name}

@@ -240,15 +250,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{product.on_sale && product.regular_price ? ( <> - + {formatPrice(product.sale_price || product.price)} - + {formatPrice(product.regular_price)} ) : ( - + {formatPrice(product.price)} )} @@ -320,7 +330,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { {/* Content */}
-

+

{product.name}

@@ -328,15 +338,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{product.on_sale && product.regular_price ? ( <> - + {formatPrice(product.sale_price || product.price)} - + {formatPrice(product.regular_price)} ) : ( - + {formatPrice(product.price)} )} @@ -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'}
@@ -376,12 +386,12 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
-

{product.name}

-
+

{product.name}

+
{formatPrice(product.price)}
diff --git a/customer-spa/src/components/SearchModal.tsx b/customer-spa/src/components/SearchModal.tsx index 2b6d119..baf649b 100644 --- a/customer-spa/src/components/SearchModal.tsx +++ b/customer-spa/src/components/SearchModal.tsx @@ -82,7 +82,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) { className="flex-1 outline-none text-lg" autoFocus /> -
diff --git a/customer-spa/src/layouts/BaseLayout.tsx b/customer-spa/src/layouts/BaseLayout.tsx index e7dea70..f3d8d91 100644 --- a/customer-spa/src/layouts/BaseLayout.tsx +++ b/customer-spa/src/layouts/BaseLayout.tsx @@ -112,51 +112,45 @@ function ClassicLayout({ children }: BaseLayoutProps) { {headerSettings.elements.search && ( )} {/* Account */} {headerSettings.elements.account && (user?.isLoggedIn ? ( - - + + + Account ) : ( - - + + + Account ))} {/* Cart */} {headerSettings.elements.cart && ( - - )} {/* Mobile Menu Toggle - Only for hamburger and slide-in */} {(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && ( @@ -218,7 +212,7 @@ function ClassicLayout({ children }: BaseLayoutProps) { {headerSettings.elements.search && ( @@ -523,7 +517,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) { {headerSettings.elements.search && ( @@ -547,7 +541,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) { {/* Mobile Menu Toggle */} + + + {/* Password Change Form - Separate */} +
+

Password Change

+ +
+
+ + 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} + /> +
+
+ + 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} + /> +

Minimum 8 characters

+
+
+ + 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} + /> +
+
+ + +
+ + ); +} diff --git a/customer-spa/src/pages/Account/Addresses.tsx b/customer-spa/src/pages/Account/Addresses.tsx new file mode 100644 index 0000000..8e37e94 --- /dev/null +++ b/customer-spa/src/pages/Account/Addresses.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editingAddress, setEditingAddress] = useState
(null); + const [formData, setFormData] = useState>({ + 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 ( +
+
Loading addresses...
+
+ ); + } + + return ( +
+
+

Addresses

+ +
+ + {addresses.length === 0 ? ( +
+ +

No addresses saved yet

+ +
+ ) : ( +
+ {addresses.map((address) => ( +
+ {address.is_default && ( +
+ +
+ )} + +
+

{address.label}

+ + {address.type === 'both' ? 'Billing & Shipping' : address.type} + +
+ +
+

{address.first_name} {address.last_name}

+ {address.company &&

{address.company}

} +

{address.address_1}

+ {address.address_2 &&

{address.address_2}

} +

{address.city}, {address.state} {address.postcode}

+

{address.country}

+ {address.phone &&

Phone: {address.phone}

} + {address.email &&

Email: {address.email}

} +
+ +
+ + + {!address.is_default && ( + + )} +
+
+ ))} +
+ )} + + {/* Address Modal */} + {showModal && ( +
+
+
+

+ {editingAddress ? 'Edit Address' : 'Add New Address'} +

+ +
+
+ + setFormData({ ...formData, label: e.target.value })} + placeholder="e.g., Home, Office, Parents" + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+ + +
+ +
+
+ + setFormData({ ...formData, first_name: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ + setFormData({ ...formData, last_name: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ +
+ + setFormData({ ...formData, company: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+ + setFormData({ ...formData, address_1: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+ + setFormData({ ...formData, address_2: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+ +
+
+ + setFormData({ ...formData, city: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ + setFormData({ ...formData, state: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ +
+
+ + setFormData({ ...formData, postcode: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ + setFormData({ ...formData, country: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ +
+
+ + setFormData({ ...formData, email: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ + setFormData({ ...formData, phone: e.target.value })} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+ +
+ setFormData({ ...formData, is_default: e.target.checked })} + className="w-4 h-4" + /> + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/customer-spa/src/pages/Account/Dashboard.tsx b/customer-spa/src/pages/Account/Dashboard.tsx new file mode 100644 index 0000000..ca164fa --- /dev/null +++ b/customer-spa/src/pages/Account/Dashboard.tsx @@ -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 ( +
+

+ Hello {user?.display_name || 'there'}! +

+ +

+ From your account dashboard you can view your recent orders, manage your shipping and billing addresses, and edit your password and account details. +

+ +
+ +
+
+ +
+
+

Orders

+

View your order history

+
+
+ + + +
+
+ +
+
+

Downloads

+

Access your downloads

+
+
+ + + +
+
+ +
+
+

Addresses

+

Manage your addresses

+
+
+ + + +
+
+ +
+
+

Account Details

+

Edit your information

+
+
+ +
+
+ ); +} diff --git a/customer-spa/src/pages/Account/Downloads.tsx b/customer-spa/src/pages/Account/Downloads.tsx new file mode 100644 index 0000000..83c2b34 --- /dev/null +++ b/customer-spa/src/pages/Account/Downloads.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Download } from 'lucide-react'; + +export default function Downloads() { + return ( +
+

Downloads

+ +
+ +

No downloads available

+
+
+ ); +} diff --git a/customer-spa/src/pages/Account/OrderDetails.tsx b/customer-spa/src/pages/Account/OrderDetails.tsx new file mode 100644 index 0000000..6da031f --- /dev/null +++ b/customer-spa/src/pages/Account/OrderDetails.tsx @@ -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(null); + + useEffect(() => { + if (orderId) { + loadOrder(); + } + }, [orderId]); + + const loadOrder = async () => { + try { + const data = await api.get(`/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 = { + '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 ( +
+
Loading order...
+
+ ); + } + + if (!order) { + return ( +
+ + + Back to Orders + +
+

Order not found

+
+
+ ); + } + + return ( +
+ + + Back to Orders + + +
+

Order #{order.order_number}

+ + {order.status.replace('-', ' ').toUpperCase()} + +
+ +
+ Placed on {formatDate(order.date)} +
+ + {/* Order Items */} +
+
+

Order Items

+
+
+ {(order.items || []).map((item) => ( +
+ {item.image && ( + {item.name} + )} +
+

{item.name}

+

Quantity: {item.quantity}

+
+
{item.total}
+
+ ))} +
+
+ + {/* Order Summary */} +
+
+

Order Summary

+
+
+
+ Subtotal + {order.subtotal} +
+
+ Shipping + {order.shipping_total} +
+
+ Tax + {order.tax_total} +
+
+ Total + {order.total} +
+
+
+ + {/* Addresses */} +
+
+
+

Billing Address

+
+
+ {order.billing && ( +
+

{order.billing.first_name} {order.billing.last_name}

+ {order.billing.company &&

{order.billing.company}

} +

{order.billing.address_1}

+ {order.billing.address_2 &&

{order.billing.address_2}

} +

{order.billing.city}, {order.billing.state} {order.billing.postcode}

+

{order.billing.country}

+ {order.billing.email &&

Email: {order.billing.email}

} + {order.billing.phone &&

Phone: {order.billing.phone}

} +
+ )} +
+
+ + {/* Only show shipping address if order needs shipping (not virtual-only) */} + {order.needs_shipping && ( +
+
+

Shipping Address

+
+
+ {order.shipping && ( +
+

{order.shipping.first_name} {order.shipping.last_name}

+ {order.shipping.company &&

{order.shipping.company}

} +

{order.shipping.address_1}

+ {order.shipping.address_2 &&

{order.shipping.address_2}

} +

{order.shipping.city}, {order.shipping.state} {order.shipping.postcode}

+

{order.shipping.country}

+
+ )} +
+
+ )} +
+ + {/* Payment Method */} +
+
+

Payment Method

+
+
+ {order.payment_method_title || 'Not specified'} +
+
+
+ ); +} diff --git a/customer-spa/src/pages/Account/Orders.tsx b/customer-spa/src/pages/Account/Orders.tsx new file mode 100644 index 0000000..0cf3c5f --- /dev/null +++ b/customer-spa/src/pages/Account/Orders.tsx @@ -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([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + useEffect(() => { + loadOrders(); + }, [page]); + + const loadOrders = async () => { + try { + const data = await api.get('/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 = { + '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 ( +
+
Loading orders...
+
+ ); + } + + if (orders.length === 0) { + return ( +
+

Orders

+ +
+ +

No orders yet

+ + Browse Products + +
+
+ ); + } + + return ( +
+

Orders

+ +
+ {orders.map((order) => ( +
+
+
+

Order #{order.order_number}

+

{formatDate(order.date)}

+
+ + {order.status.replace('-', ' ').toUpperCase()} + +
+ +
+
+ {order.items_count} {order.items_count === 1 ? 'item' : 'items'} +
+
+ {order.total} + + + View + +
+
+
+ ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ); +} diff --git a/customer-spa/src/pages/Account/components/AccountLayout.tsx b/customer-spa/src/pages/Account/components/AccountLayout.tsx new file mode 100644 index 0000000..74d2dc9 --- /dev/null +++ b/customer-spa/src/pages/Account/components/AccountLayout.tsx @@ -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 = () => ( + + ); + + // Tab Navigation (Mobile) + const TabNav = () => ( +
+ +
+ ); + + // Responsive layout: Tabs on mobile, Sidebar on desktop + return ( +
+ {/* Mobile: Tab Navigation */} + + + {/* Desktop: Sidebar + Content */} +
+
+ +
+
+
+ {children} +
+
+
+
+ ); +} diff --git a/customer-spa/src/pages/Account/index.tsx b/customer-spa/src/pages/Account/index.tsx index 53b8969..8199dab 100644 --- a/customer-spa/src/pages/Account/index.tsx +++ b/customer-spa/src/pages/Account/index.tsx @@ -1,70 +1,36 @@ import React from 'react'; -import { Routes, Route, Link } from 'react-router-dom'; - -function Dashboard() { - return ( -
-

My Account

-

Welcome to your account dashboard.

-
- ); -} - -function Orders() { - return ( -
-

Orders

-

Your order history will appear here.

-
- ); -} - -function Profile() { - return ( -
-

Profile

-

Edit your profile information.

-
- ); -} +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 ( -
-
- {/* Sidebar Navigation */} - - - {/* Main Content */} -
- - } /> - } /> - } /> - -
-
-
+ + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/customer-spa/src/pages/Cart/index.tsx b/customer-spa/src/pages/Cart/index.tsx index e0259bc..e064151 100644 --- a/customer-spa/src/pages/Cart/index.tsx +++ b/customer-spa/src/pages/Cart/index.tsx @@ -126,7 +126,7 @@ export default function Cart() {
@@ -141,7 +141,7 @@ export default function Cart() { /> @@ -152,7 +152,7 @@ export default function Cart() {
diff --git a/customer-spa/src/pages/Checkout/index.tsx b/customer-spa/src/pages/Checkout/index.tsx index fd9977b..7a24f9a 100644 --- a/customer-spa/src/pages/Checkout/index.tsx +++ b/customer-spa/src/pages/Checkout/index.tsx @@ -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([]); + const [selectedBillingAddressId, setSelectedBillingAddressId] = useState(null); + const [selectedShippingAddressId, setSelectedShippingAddressId] = useState(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('/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() {
{/* Billing & Shipping Forms */}
- {/* Billing Details */} + {/* Selected Billing Address Summary */} + {!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'billing' || a.type === 'both') && ( +
+
+

+ + Billing Address +

+ +
+ + {selectedBillingAddressId ? ( + (() => { + const selected = savedAddresses.find(a => a.id === selectedBillingAddressId); + return selected ? ( +
+
+
+

{selected.label}

+ {selected.is_default && ( + Default + )} +
+

{selected.first_name} {selected.last_name}

+ {selected.phone &&

{selected.phone}

} +

{selected.address_1}

+ {selected.address_2 &&

{selected.address_2}

} +

{selected.city}, {selected.state} {selected.postcode}

+

{selected.country}

+
+ +
+ ) : null; + })() + ) : ( +

No address selected

+ )} +
+ )} + + {/* Billing Address Modal */} + 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) && (

Billing Details

@@ -285,6 +458,7 @@ export default function Checkout() { )}
+ )} {/* Ship to Different Address - only for physical products */} {!isVirtualOnly && ( @@ -300,7 +474,77 @@ export default function Checkout() { {shipToDifferentAddress && ( -
+ <> + {/* Selected Shipping Address Summary */} + {!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && ( +
+
+

+ + Shipping Address +

+ +
+ + {selectedShippingAddressId ? ( + (() => { + const selected = savedAddresses.find(a => a.id === selectedShippingAddressId); + return selected ? ( +
+
+
+

{selected.label}

+ {selected.is_default && ( + Default + )} +
+

{selected.first_name} {selected.last_name}

+ {selected.phone &&

{selected.phone}

} +

{selected.address_1}

+ {selected.address_2 &&

{selected.address_2}

} +

{selected.city}, {selected.state} {selected.postcode}

+

{selected.country}

+
+ +
+ ) : null; + })() + ) : ( +

No address selected

+ )} +
+ )} + + {/* Shipping Address Modal */} + 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) && ( +
+ )} + )}
)} diff --git a/customer-spa/src/pages/Shop/index.tsx b/customer-spa/src/pages/Shop/index.tsx index 16a7341..2cc72e2 100644 --- a/customer-spa/src/pages/Shop/index.tsx +++ b/customer-spa/src/pages/Shop/index.tsx @@ -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 && ( diff --git a/includes/Api/CheckoutController.php b/includes/Api/CheckoutController.php index bffc59c..40d413e 100644 --- a/includes/Api/CheckoutController.php +++ b/includes/Api/CheckoutController.php @@ -158,6 +158,37 @@ class CheckoutController { return ['error' => $order->get_error_message()]; } + // Set customer ID if user is logged in + if (is_user_logged_in()) { + $user_id = get_current_user_id(); + $order->set_customer_id($user_id); + + // Update user's billing information from checkout data + if (!empty($payload['billing'])) { + $billing = $payload['billing']; + + // Update first name and last name + if (!empty($billing['first_name'])) { + update_user_meta($user_id, 'first_name', sanitize_text_field($billing['first_name'])); + update_user_meta($user_id, 'billing_first_name', sanitize_text_field($billing['first_name'])); + } + if (!empty($billing['last_name'])) { + update_user_meta($user_id, 'last_name', sanitize_text_field($billing['last_name'])); + update_user_meta($user_id, 'billing_last_name', sanitize_text_field($billing['last_name'])); + } + + // Update billing phone + if (!empty($billing['phone'])) { + update_user_meta($user_id, 'billing_phone', sanitize_text_field($billing['phone'])); + } + + // Update billing email + if (!empty($billing['email'])) { + update_user_meta($user_id, 'billing_email', sanitize_email($billing['email'])); + } + } + } + // Add items foreach ($payload['items'] as $line) { $product = $this->load_product($line); @@ -352,7 +383,7 @@ class CheckoutController { if (!WC()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); } if (!WC()->cart) { WC()->cart = new \WC_Cart(); } - // Address context for taxes/shipping rules + // Address context for taxes/shipping rules - set temporarily without saving to user profile $ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing']; if (!empty($payload['billing'])) { foreach (['country','state','postcode','city','address_1','address_2'] as $k) { @@ -370,7 +401,8 @@ class CheckoutController { } } } - WC()->customer->save(); + // DO NOT save customer data - only use for quote calculation + // WC()->customer->save(); // REMOVED - should not update user profile during checkout WC()->cart->empty_cart(true); foreach ($payload['items'] as $line) { diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index e9df0b6..a368537 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -24,6 +24,7 @@ use WooNooW\Api\NewsletterController; use WooNooW\Frontend\ShopController; use WooNooW\Frontend\CartController as FrontendCartController; use WooNooW\Frontend\AccountController; +use WooNooW\Frontend\AddressController; use WooNooW\Frontend\HookBridge; use WooNooW\Api\Controllers\SettingsController; use WooNooW\Api\Controllers\CartController as ApiCartController; @@ -38,20 +39,7 @@ class Routes { // Initialize CartController auth bypass (must be before rest_api_init) FrontendCartController::init(); - // Log ALL REST API requests to debug routing - add_filter('rest_pre_dispatch', function($result, $server, $request) { - $route = $request->get_route(); - $method = $request->get_method(); - $result_type = is_null($result) ? 'NULL (will call handler)' : 'NON-NULL (handler bypassed!)'; - error_log("WooNooW REST: {$method} {$route} - Result: {$result_type}"); - if (!is_null($result)) { - error_log("WooNooW REST: BYPASSED! Result type: " . gettype($result)); - } - return $result; - }, 10, 3); - add_action('rest_api_init', function () { - error_log('WooNooW Routes: rest_api_init hook fired'); $namespace = 'woonoow/v1'; // Auth endpoints (public - no permission check) @@ -82,10 +70,6 @@ class Routes { $settings_controller = new SettingsController(); $settings_controller->register_routes(); - // Cart controller (API) - DISABLED: Using Frontend CartController instead to avoid route conflicts - // $api_cart_controller = new ApiCartController(); - // $api_cart_controller->register_routes(); - // Payments controller $payments_controller = new PaymentsController(); $payments_controller->register_routes(); @@ -127,9 +111,7 @@ class Routes { $activity_log_controller->register_routes(); // Products controller - error_log('WooNooW Routes: Registering ProductsController routes'); ProductsController::register_routes(); - error_log('WooNooW Routes: ProductsController routes registered'); // Coupons controller CouponsController::register_routes(); @@ -141,12 +123,11 @@ class Routes { NewsletterController::register_routes(); // Frontend controllers (customer-facing) - error_log('WooNooW Routes: Registering Frontend controllers'); ShopController::register_routes(); FrontendCartController::register_routes(); AccountController::register_routes(); + AddressController::register_routes(); HookBridge::register_routes(); - error_log('WooNooW Routes: Frontend controllers registered'); }); } } diff --git a/includes/Compat/CustomerSettingsProvider.php b/includes/Compat/CustomerSettingsProvider.php index 86d2f50..22e219f 100644 --- a/includes/Compat/CustomerSettingsProvider.php +++ b/includes/Compat/CustomerSettingsProvider.php @@ -20,6 +20,7 @@ class CustomerSettingsProvider { return [ // General 'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes', + 'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes', // VIP Customer Qualification 'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)), @@ -44,6 +45,10 @@ class CustomerSettingsProvider { $updated = $updated && update_option('woonoow_auto_register_members', $settings['auto_register_members'] ? 'yes' : 'no'); } + if (isset($settings['multiple_addresses_enabled'])) { + $updated = $updated && update_option('woonoow_multiple_addresses_enabled', $settings['multiple_addresses_enabled'] ? 'yes' : 'no'); + } + // VIP settings if (isset($settings['vip_min_spent'])) { $updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent'])); diff --git a/includes/Frontend/AccountController.php b/includes/Frontend/AccountController.php index a148ef3..1dbb630 100644 --- a/includes/Frontend/AccountController.php +++ b/includes/Frontend/AccountController.php @@ -79,19 +79,7 @@ class AccountController { ], ]); - // Get addresses - register_rest_route($namespace, '/account/addresses', [ - [ - 'methods' => 'GET', - 'callback' => [__CLASS__, 'get_addresses'], - 'permission_callback' => [__CLASS__, 'check_customer_permission'], - ], - [ - 'methods' => 'POST', - 'callback' => [__CLASS__, 'update_addresses'], - 'permission_callback' => [__CLASS__, 'check_customer_permission'], - ], - ]); + // Address routes moved to AddressController // Get downloads (for digital products) register_rest_route($namespace, '/account/downloads', [ @@ -330,34 +318,51 @@ class AccountController { * Format order data for API response */ private static function format_order($order, $detailed = false) { + $payment_title = $order->get_payment_method_title(); + if (empty($payment_title)) { + $payment_title = $order->get_payment_method() ?: 'Not specified'; + } + $data = [ 'id' => $order->get_id(), 'order_number' => $order->get_order_number(), 'status' => $order->get_status(), - 'date_created' => $order->get_date_created()->date('Y-m-d H:i:s'), - 'total' => $order->get_total(), + 'date' => $order->get_date_created()->date('Y-m-d H:i:s'), + 'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))), 'currency' => $order->get_currency(), - 'payment_method' => $order->get_payment_method_title(), + 'payment_method_title' => $payment_title, ]; if ($detailed) { - $data['items'] = array_map(function($item) { + $items = $order->get_items(); + $data['items'] = is_array($items) ? array_values(array_map(function($item) { $product = $item->get_product(); return [ 'id' => $item->get_id(), 'name' => $item->get_name(), 'quantity' => $item->get_quantity(), - 'total' => $item->get_total(), + 'total' => html_entity_decode(strip_tags(wc_price($item->get_total()))), 'image' => $product ? wp_get_attachment_url($product->get_image_id()) : '', ]; - }, $order->get_items()); + }, $items)) : []; + + // Check if order needs shipping (not virtual-only) + $needs_shipping = false; + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + if ($product && !$product->is_virtual()) { + $needs_shipping = true; + break; + } + } $data['billing'] = $order->get_address('billing'); $data['shipping'] = $order->get_address('shipping'); - $data['subtotal'] = $order->get_subtotal(); - $data['shipping_total'] = $order->get_shipping_total(); - $data['tax_total'] = $order->get_total_tax(); - $data['discount_total'] = $order->get_discount_total(); + $data['needs_shipping'] = $needs_shipping; + $data['subtotal'] = html_entity_decode(strip_tags(wc_price($order->get_subtotal()))); + $data['shipping_total'] = html_entity_decode(strip_tags(wc_price($order->get_shipping_total()))); + $data['tax_total'] = html_entity_decode(strip_tags(wc_price($order->get_total_tax()))); + $data['discount_total'] = html_entity_decode(strip_tags(wc_price($order->get_discount_total()))); } return $data; diff --git a/includes/Frontend/AddressController.php b/includes/Frontend/AddressController.php new file mode 100644 index 0000000..2ddbf91 --- /dev/null +++ b/includes/Frontend/AddressController.php @@ -0,0 +1,241 @@ + 'GET', + 'callback' => [__CLASS__, 'get_addresses'], + 'permission_callback' => [__CLASS__, 'check_customer_permission'], + ], + [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'create_address'], + 'permission_callback' => [__CLASS__, 'check_customer_permission'], + ], + ]); + + // Update address + register_rest_route($namespace, '/account/addresses/(?P\d+)', [ + 'methods' => 'PUT', + 'callback' => [__CLASS__, 'update_address'], + 'permission_callback' => [__CLASS__, 'check_customer_permission'], + ]); + + // Delete address + register_rest_route($namespace, '/account/addresses/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [__CLASS__, 'delete_address'], + 'permission_callback' => [__CLASS__, 'check_customer_permission'], + ]); + + // Set default address + register_rest_route($namespace, '/account/addresses/(?P\d+)/set-default', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'set_default_address'], + 'permission_callback' => [__CLASS__, 'check_customer_permission'], + ]); + } + + /** + * Check if user is logged in + */ + public static function check_customer_permission() { + return is_user_logged_in(); + } + + /** + * Get all addresses for current user + */ + public static function get_addresses(WP_REST_Request $request) { + $user_id = get_current_user_id(); + $addresses = get_user_meta($user_id, 'woonoow_addresses', true); + + if (!$addresses || !is_array($addresses)) { + $addresses = []; + } + + $addresses = array_values($addresses); + + return new WP_REST_Response($addresses, 200); + } + + /** + * Create new address + */ + public static function create_address(WP_REST_Request $request) { + $user_id = get_current_user_id(); + $addresses = get_user_meta($user_id, 'woonoow_addresses', true); + + if (!is_array($addresses)) { + $addresses = []; + } + + // Generate new ID + $new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1; + + // Prepare address data + $address = [ + 'id' => $new_id, + 'label' => sanitize_text_field($request->get_param('label')), + 'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both' + 'first_name' => sanitize_text_field($request->get_param('first_name')), + 'last_name' => sanitize_text_field($request->get_param('last_name')), + 'company' => sanitize_text_field($request->get_param('company')), + 'address_1' => sanitize_text_field($request->get_param('address_1')), + 'address_2' => sanitize_text_field($request->get_param('address_2')), + 'city' => sanitize_text_field($request->get_param('city')), + 'state' => sanitize_text_field($request->get_param('state')), + 'postcode' => sanitize_text_field($request->get_param('postcode')), + 'country' => sanitize_text_field($request->get_param('country')), + 'email' => sanitize_email($request->get_param('email')), + 'phone' => sanitize_text_field($request->get_param('phone')), + 'is_default' => (bool) $request->get_param('is_default'), + ]; + + // If this is set as default, unset other defaults of the same type + if ($address['is_default']) { + foreach ($addresses as &$addr) { + if ($addr['type'] === $address['type'] || $addr['type'] === 'both' || $address['type'] === 'both') { + $addr['is_default'] = false; + } + } + } + + $addresses[] = $address; + + update_user_meta($user_id, 'woonoow_addresses', $addresses); + + return new WP_REST_Response($address, 201); + } + + /** + * Update existing address + */ + public static function update_address(WP_REST_Request $request) { + $user_id = get_current_user_id(); + $address_id = (int) $request->get_param('id'); + $addresses = get_user_meta($user_id, 'woonoow_addresses', true); + + if (!is_array($addresses)) { + return new WP_Error('no_addresses', 'No addresses found', ['status' => 404]); + } + + $found = false; + foreach ($addresses as &$addr) { + if ($addr['id'] === $address_id) { + $found = true; + + // Update fields + $addr['label'] = sanitize_text_field($request->get_param('label')); + $addr['type'] = sanitize_text_field($request->get_param('type')); + $addr['first_name'] = sanitize_text_field($request->get_param('first_name')); + $addr['last_name'] = sanitize_text_field($request->get_param('last_name')); + $addr['company'] = sanitize_text_field($request->get_param('company')); + $addr['address_1'] = sanitize_text_field($request->get_param('address_1')); + $addr['address_2'] = sanitize_text_field($request->get_param('address_2')); + $addr['city'] = sanitize_text_field($request->get_param('city')); + $addr['state'] = sanitize_text_field($request->get_param('state')); + $addr['postcode'] = sanitize_text_field($request->get_param('postcode')); + $addr['country'] = sanitize_text_field($request->get_param('country')); + $addr['email'] = sanitize_email($request->get_param('email')); + $addr['phone'] = sanitize_text_field($request->get_param('phone')); + $addr['is_default'] = (bool) $request->get_param('is_default'); + + // If this is set as default, unset other defaults of the same type + if ($addr['is_default']) { + foreach ($addresses as &$other_addr) { + if ($other_addr['id'] !== $address_id) { + if ($other_addr['type'] === $addr['type'] || $other_addr['type'] === 'both' || $addr['type'] === 'both') { + $other_addr['is_default'] = false; + } + } + } + } + + break; + } + } + + if (!$found) { + return new WP_Error('address_not_found', 'Address not found', ['status' => 404]); + } + + update_user_meta($user_id, 'woonoow_addresses', $addresses); + + return new WP_REST_Response(['success' => true], 200); + } + + /** + * Delete address + */ + public static function delete_address(WP_REST_Request $request) { + $user_id = get_current_user_id(); + $address_id = (int) $request->get_param('id'); + $addresses = get_user_meta($user_id, 'woonoow_addresses', true); + + if (!is_array($addresses)) { + return new WP_Error('no_addresses', 'No addresses found', ['status' => 404]); + } + + $addresses = array_filter($addresses, function($addr) use ($address_id) { + return $addr['id'] !== $address_id; + }); + + // Re-index array + $addresses = array_values($addresses); + + update_user_meta($user_id, 'woonoow_addresses', $addresses); + + return new WP_REST_Response(['success' => true], 200); + } + + /** + * Set address as default + */ + public static function set_default_address(WP_REST_Request $request) { + $user_id = get_current_user_id(); + $address_id = (int) $request->get_param('id'); + $addresses = get_user_meta($user_id, 'woonoow_addresses', true); + + if (!is_array($addresses)) { + return new WP_Error('no_addresses', 'No addresses found', ['status' => 404]); + } + + $found = false; + $address_type = null; + + foreach ($addresses as &$addr) { + if ($addr['id'] === $address_id) { + $found = true; + $address_type = $addr['type']; + $addr['is_default'] = true; + } else { + // Unset default for addresses of the same type + if ($address_type && ($addr['type'] === $address_type || $addr['type'] === 'both' || $address_type === 'both')) { + $addr['is_default'] = false; + } + } + } + + if (!$found) { + return new WP_Error('address_not_found', 'Address not found', ['status' => 404]); + } + + update_user_meta($user_id, 'woonoow_addresses', $addresses); + + return new WP_REST_Response(['success' => true], 200); + } +} diff --git a/includes/Frontend/CartController.php b/includes/Frontend/CartController.php index 790cdf2..9a47bee 100644 --- a/includes/Frontend/CartController.php +++ b/includes/Frontend/CartController.php @@ -25,7 +25,6 @@ class CartController { // Check if this is a cart endpoint $request_uri = $_SERVER['REQUEST_URI'] ?? ''; if (strpos($request_uri, '/woonoow/v1/cart') !== false) { - error_log('WooNooW Cart: Bypassing authentication for cart endpoint'); return true; // Allow access } @@ -37,7 +36,6 @@ class CartController { * Register REST API routes */ public static function register_routes() { - error_log('WooNooW CartController::register_routes() START'); $namespace = 'woonoow/v1'; // Get cart @@ -46,7 +44,6 @@ class CartController { 'callback' => [__CLASS__, 'get_cart'], 'permission_callback' => '__return_true', ]); - error_log('WooNooW CartController: GET /cart registered: ' . ($result ? 'SUCCESS' : 'FAILED')); // Add to cart $result = register_rest_route($namespace, '/cart/add', [ @@ -73,7 +70,6 @@ class CartController { ], ], ]); - error_log('WooNooW CartController: POST /cart/add registered: ' . ($result ? 'SUCCESS' : 'FAILED')); // Update cart item register_rest_route($namespace, '/cart/update', [