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