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,
|
style,
|
||||||
sticky,
|
sticky,
|
||||||
height,
|
height,
|
||||||
mobile_menu: mobileMenu,
|
mobileMenu,
|
||||||
mobile_logo: mobileLogo,
|
mobileLogo,
|
||||||
logo_width: logoWidth,
|
logoWidth,
|
||||||
logo_height: logoHeight,
|
logoHeight,
|
||||||
elements,
|
elements,
|
||||||
});
|
});
|
||||||
toast.success('Header settings saved successfully');
|
toast.success('Header settings saved successfully');
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { api } from '@/lib/api';
|
|||||||
|
|
||||||
export default function AppearanceShop() {
|
export default function AppearanceShop() {
|
||||||
const [loading, setLoading] = useState(true);
|
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 [gridStyle, setGridStyle] = useState('standard');
|
||||||
const [cardStyle, setCardStyle] = useState('card');
|
const [cardStyle, setCardStyle] = useState('card');
|
||||||
const [aspectRatio, setAspectRatio] = useState('square');
|
const [aspectRatio, setAspectRatio] = useState('square');
|
||||||
@@ -37,7 +41,11 @@ export default function AppearanceShop() {
|
|||||||
const shop = response.data?.pages?.shop;
|
const shop = response.data?.pages?.shop;
|
||||||
|
|
||||||
if (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');
|
setGridStyle(shop.layout?.grid_style || 'standard');
|
||||||
setCardStyle(shop.layout?.card_style || 'card');
|
setCardStyle(shop.layout?.card_style || 'card');
|
||||||
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
||||||
@@ -110,17 +118,55 @@ export default function AppearanceShop() {
|
|||||||
title="Layout"
|
title="Layout"
|
||||||
description="Configure shop page layout and product display"
|
description="Configure shop page layout and product display"
|
||||||
>
|
>
|
||||||
<SettingsSection label="Grid Columns" htmlFor="grid-columns">
|
<SettingsSection label="Grid Columns" description="Set columns for each breakpoint">
|
||||||
<Select value={gridColumns} onValueChange={setGridColumns}>
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<SelectTrigger id="grid-columns">
|
<div>
|
||||||
<SelectValue />
|
<Label htmlFor="grid-columns-mobile" className="text-sm font-medium mb-2 block">Mobile</Label>
|
||||||
</SelectTrigger>
|
<Select value={gridColumns.mobile} onValueChange={(value) => setGridColumns({ ...gridColumns, mobile: value })}>
|
||||||
<SelectContent>
|
<SelectTrigger id="grid-columns-mobile">
|
||||||
<SelectItem value="2">2 Columns</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="3">3 Columns</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="4">4 Columns</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
<SelectItem value="1">1</SelectItem>
|
||||||
</Select>
|
<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>
|
||||||
|
|
||||||
<SettingsSection label="Grid Style" htmlFor="grid-style" description="Masonry creates a Pinterest-like layout with varying heights">
|
<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 {
|
interface CustomerSettings {
|
||||||
auto_register_members: boolean;
|
auto_register_members: boolean;
|
||||||
|
multiple_addresses_enabled: boolean;
|
||||||
vip_min_spent: number;
|
vip_min_spent: number;
|
||||||
vip_min_orders: number;
|
vip_min_orders: number;
|
||||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||||
@@ -22,6 +23,7 @@ interface CustomerSettings {
|
|||||||
export default function CustomersSettings() {
|
export default function CustomersSettings() {
|
||||||
const [settings, setSettings] = useState<CustomerSettings>({
|
const [settings, setSettings] = useState<CustomerSettings>({
|
||||||
auto_register_members: false,
|
auto_register_members: false,
|
||||||
|
multiple_addresses_enabled: true,
|
||||||
vip_min_spent: 1000,
|
vip_min_spent: 1000,
|
||||||
vip_min_orders: 10,
|
vip_min_orders: 10,
|
||||||
vip_timeframe: 'all',
|
vip_timeframe: 'all',
|
||||||
@@ -119,13 +121,23 @@ export default function CustomersSettings() {
|
|||||||
title={__('General')}
|
title={__('General')}
|
||||||
description={__('General customer settings')}
|
description={__('General customer settings')}
|
||||||
>
|
>
|
||||||
<ToggleField
|
<div className="space-y-6">
|
||||||
id="auto_register_members"
|
<ToggleField
|
||||||
label={__('Auto-register customers as site members')}
|
id="auto_register_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.')}
|
label={__('Auto-register customers as site members')}
|
||||||
checked={settings.auto_register_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.')}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
|
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>
|
||||||
|
|
||||||
<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
|
<button
|
||||||
type="submit"
|
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" />
|
<Mail className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Subscribing...' : 'Subscribe'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ProductCardProps {
|
|||||||
image?: string;
|
image?: string;
|
||||||
on_sale?: boolean;
|
on_sale?: boolean;
|
||||||
stock_status?: string;
|
stock_status?: string;
|
||||||
|
type?: string;
|
||||||
};
|
};
|
||||||
onAddToCart?: (product: any) => void;
|
onAddToCart?: (product: any) => void;
|
||||||
}
|
}
|
||||||
@@ -32,9 +33,18 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
'landscape': 'aspect-[4/3]',
|
'landscape': 'aspect-[4/3]',
|
||||||
}[layout.aspect_ratio] || 'aspect-square';
|
}[layout.aspect_ratio] || 'aspect-square';
|
||||||
|
|
||||||
|
const isVariable = product.type === 'variable';
|
||||||
|
|
||||||
const handleAddToCart = (e: React.MouseEvent) => {
|
const handleAddToCart = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Variable products need to go to product page for attribute selection
|
||||||
|
if (isVariable) {
|
||||||
|
window.location.href = `/product/${product.slug}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onAddToCart?.(product);
|
onAddToCart?.(product);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +132,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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" />
|
<Heart className="w-4 h-4 block" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +147,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
disabled={product.stock_status === 'outofstock'}
|
disabled={product.stock_status === 'outofstock'}
|
||||||
>
|
>
|
||||||
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
{!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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +155,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
<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}
|
{product.name}
|
||||||
</h3>
|
</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' : ''}`}>
|
<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 ? (
|
{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)}
|
{formatPrice(product.sale_price || product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500 line-through">
|
<span className="text-xs text-gray-500 line-through">
|
||||||
{formatPrice(product.regular_price)}
|
{formatPrice(product.regular_price)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-lg font-bold text-gray-900">
|
<span className="text-base font-bold text-gray-900">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -176,7 +186,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
disabled={product.stock_status === 'outofstock'}
|
disabled={product.stock_status === 'outofstock'}
|
||||||
>
|
>
|
||||||
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
{!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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +234,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
disabled={product.stock_status === 'outofstock'}
|
disabled={product.stock_status === 'outofstock'}
|
||||||
>
|
>
|
||||||
{addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
{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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -232,7 +242,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="text-center">
|
<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}
|
{product.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -240,15 +250,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<div className="flex items-center justify-center gap-2 mb-3">
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
{product.on_sale && product.regular_price ? (
|
{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)}
|
{formatPrice(product.sale_price || product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-400 line-through">
|
<span className="text-xs text-gray-400 line-through">
|
||||||
{formatPrice(product.regular_price)}
|
{formatPrice(product.regular_price)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-semibold text-gray-900">
|
<span className="text-base font-bold text-gray-900">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -320,7 +330,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="text-center font-serif">
|
<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}
|
{product.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -328,15 +338,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<div className="flex items-center justify-center gap-3 mb-4">
|
<div className="flex items-center justify-center gap-3 mb-4">
|
||||||
{product.on_sale && product.regular_price ? (
|
{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)}
|
{formatPrice(product.sale_price || product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400 line-through">
|
<span className="text-sm text-gray-400 line-through">
|
||||||
{formatPrice(product.regular_price)}
|
{formatPrice(product.regular_price)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xl font-medium text-gray-900">
|
<span className="text-lg font-semibold text-gray-900">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -349,7 +359,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
className="w-full font-serif tracking-wider"
|
className="w-full font-serif tracking-wider"
|
||||||
disabled={product.stock_status === 'outofstock'}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,12 +386,12 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">{product.name}</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug">{product.name}</h3>
|
||||||
<div className="text-xl font-bold mb-3" style={{ color: 'var(--color-primary)' }}>
|
<div className="text-lg font-bold mb-3" style={{ color: 'var(--color-primary)' }}>
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddToCart} className="w-full" size="lg">
|
<Button onClick={handleAddToCart} className="w-full" size="lg">
|
||||||
Buy Now
|
{isVariable ? 'Select Options' : 'Buy Now'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
className="flex-1 outline-none text-lg"
|
className="flex-1 outline-none text-lg"
|
||||||
autoFocus
|
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" />
|
<X className="h-5 w-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,51 +112,45 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
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" />
|
<Search className="h-5 w-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||||
<Link to="/my-account" className="no-underline">
|
<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">
|
||||||
<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" />
|
||||||
<User className="h-5 w-5 text-gray-600" />
|
<span className="hidden lg:block">Account</span>
|
||||||
<span className="hidden lg:block text-sm font-medium text-gray-700">Account</span>
|
|
||||||
</button>
|
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<a href="/wp-login.php" className="no-underline">
|
<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">
|
||||||
<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" />
|
||||||
<User className="h-5 w-5 text-gray-600" />
|
<span className="hidden lg:block">Account</span>
|
||||||
<span className="hidden lg:block text-sm font-medium text-gray-700">Account</span>
|
|
||||||
</button>
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Cart */}
|
{/* Cart */}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="no-underline">
|
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ShoppingCart className="h-5 w-5 text-gray-600" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{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">
|
<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}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden lg:block text-sm font-medium text-gray-700">
|
<span className="hidden lg:block">
|
||||||
Cart ({itemCount})
|
Cart ({itemCount})
|
||||||
</span>
|
</span>
|
||||||
</button>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||||
{(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && (
|
{(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && (
|
||||||
<button
|
<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)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
{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="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">
|
<div className="p-4 border-b flex justify-between items-center">
|
||||||
<span className="font-semibold">Menu</span>
|
<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" />
|
<X className="h-5 w-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +212,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
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" />
|
<Search className="h-5 w-5" />
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
@@ -395,7 +389,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
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" />
|
<Search className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -523,7 +517,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
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" />
|
<Search className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -547,7 +541,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<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)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
{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 React from 'react';
|
||||||
import { Routes, Route, Link } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Container from '@/components/Layout/Container';
|
||||||
function Dashboard() {
|
import { AccountLayout } from './components/AccountLayout';
|
||||||
return (
|
import Dashboard from './Dashboard';
|
||||||
<div>
|
import Orders from './Orders';
|
||||||
<h2 className="text-2xl font-bold mb-4">My Account</h2>
|
import OrderDetails from './OrderDetails';
|
||||||
<p className="text-muted-foreground">Welcome to your account dashboard.</p>
|
import Downloads from './Downloads';
|
||||||
</div>
|
import Addresses from './Addresses';
|
||||||
);
|
import AccountDetails from './AccountDetails';
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Account() {
|
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 (
|
return (
|
||||||
<div className="container-safe py-8">
|
<Container>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<AccountLayout>
|
||||||
{/* Sidebar Navigation */}
|
<Routes>
|
||||||
<aside className="md:col-span-1">
|
<Route index element={<Dashboard />} />
|
||||||
<nav className="space-y-2">
|
<Route path="orders" element={<Orders />} />
|
||||||
<Link
|
<Route path="orders/:orderId" element={<OrderDetails />} />
|
||||||
to="/account"
|
<Route path="downloads" element={<Downloads />} />
|
||||||
className="block px-4 py-2 rounded-lg hover:bg-accent"
|
<Route path="addresses" element={<Addresses />} />
|
||||||
>
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
Dashboard
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Link>
|
</Routes>
|
||||||
<Link
|
</AccountLayout>
|
||||||
to="/account/orders"
|
</Container>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function Cart() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
|
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" />
|
<Minus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +141,7 @@ export default function Cart() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -152,7 +152,7 @@ export default function Cart() {
|
|||||||
<div className="flex flex-col items-end justify-between">
|
<div className="flex flex-col items-end justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveItem(item.key)}
|
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" />
|
<Trash2 className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,9 +5,29 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { formatPrice } from '@/lib/currency';
|
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 { toast } from 'sonner';
|
||||||
import { apiClient } from '@/lib/api/client';
|
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() {
|
export default function Checkout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -54,10 +74,95 @@ export default function Checkout() {
|
|||||||
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
||||||
const [orderNotes, setOrderNotes] = useState('');
|
const [orderNotes, setOrderNotes] = useState('');
|
||||||
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
|
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
|
// Auto-fill form with user data if logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.isLoggedIn && user?.billing) {
|
if (user?.isLoggedIn && user?.billing && savedAddresses.length === 0) {
|
||||||
setBillingData({
|
setBillingData({
|
||||||
firstName: user.billing.first_name || '',
|
firstName: user.billing.first_name || '',
|
||||||
lastName: user.billing.last_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'}`}>
|
<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 */}
|
{/* Billing & Shipping Forms */}
|
||||||
<div className={`space-y-6 ${layout.style === 'single-column' || layout.order_summary === 'top' ? '' : 'lg:col-span-2'}`}>
|
<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">
|
<div className="bg-white border rounded-lg p-6">
|
||||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -285,6 +458,7 @@ export default function Checkout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ship to Different Address - only for physical products */}
|
{/* Ship to Different Address - only for physical products */}
|
||||||
{!isVirtualOnly && (
|
{!isVirtualOnly && (
|
||||||
@@ -300,7 +474,77 @@ export default function Checkout() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{shipToDifferentAddress && (
|
{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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||||
<input
|
<input
|
||||||
@@ -372,6 +616,8 @@ export default function Checkout() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,21 +23,56 @@ export default function Shop() {
|
|||||||
const [sortBy, setSortBy] = useState('');
|
const [sortBy, setSortBy] = useState('');
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
|
|
||||||
// Map grid columns setting to Tailwind classes
|
// Map grid columns setting to Tailwind classes (responsive)
|
||||||
const gridColsClass = {
|
const gridCols = typeof shopLayout.grid_columns === 'object'
|
||||||
'2': 'grid-cols-1 sm:grid-cols-2',
|
? shopLayout.grid_columns
|
||||||
'3': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
: { mobile: '2', tablet: '3', desktop: '4' };
|
||||||
'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';
|
|
||||||
|
|
||||||
// Masonry column classes (CSS columns)
|
// Map to actual Tailwind classes (can't use template literals due to purging)
|
||||||
const masonryColsClass = {
|
const mobileClass = {
|
||||||
'2': 'columns-1 sm:columns-2',
|
'1': 'grid-cols-1',
|
||||||
'3': 'columns-1 sm:columns-2 lg:columns-3',
|
'2': 'grid-cols-2',
|
||||||
'4': 'columns-1 sm:columns-2 lg:columns-3 xl:columns-4',
|
'3': 'grid-cols-3',
|
||||||
}[shopLayout.grid_columns] || 'columns-1 sm:columns-2 lg:columns-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';
|
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||||
|
|
||||||
@@ -114,7 +149,7 @@ export default function Shop() {
|
|||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch('')}
|
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" />
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -158,6 +158,37 @@ class CheckoutController {
|
|||||||
return ['error' => $order->get_error_message()];
|
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
|
// Add items
|
||||||
foreach ($payload['items'] as $line) {
|
foreach ($payload['items'] as $line) {
|
||||||
$product = $this->load_product($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()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); }
|
||||||
if (!WC()->cart) { WC()->cart = new \WC_Cart(); }
|
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'];
|
$ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing'];
|
||||||
if (!empty($payload['billing'])) {
|
if (!empty($payload['billing'])) {
|
||||||
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
|
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);
|
WC()->cart->empty_cart(true);
|
||||||
foreach ($payload['items'] as $line) {
|
foreach ($payload['items'] as $line) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use WooNooW\Api\NewsletterController;
|
|||||||
use WooNooW\Frontend\ShopController;
|
use WooNooW\Frontend\ShopController;
|
||||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||||
use WooNooW\Frontend\AccountController;
|
use WooNooW\Frontend\AccountController;
|
||||||
|
use WooNooW\Frontend\AddressController;
|
||||||
use WooNooW\Frontend\HookBridge;
|
use WooNooW\Frontend\HookBridge;
|
||||||
use WooNooW\Api\Controllers\SettingsController;
|
use WooNooW\Api\Controllers\SettingsController;
|
||||||
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
||||||
@@ -38,20 +39,7 @@ class Routes {
|
|||||||
// Initialize CartController auth bypass (must be before rest_api_init)
|
// Initialize CartController auth bypass (must be before rest_api_init)
|
||||||
FrontendCartController::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 () {
|
add_action('rest_api_init', function () {
|
||||||
error_log('WooNooW Routes: rest_api_init hook fired');
|
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
// Auth endpoints (public - no permission check)
|
// Auth endpoints (public - no permission check)
|
||||||
@@ -82,10 +70,6 @@ class Routes {
|
|||||||
$settings_controller = new SettingsController();
|
$settings_controller = new SettingsController();
|
||||||
$settings_controller->register_routes();
|
$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
|
||||||
$payments_controller = new PaymentsController();
|
$payments_controller = new PaymentsController();
|
||||||
$payments_controller->register_routes();
|
$payments_controller->register_routes();
|
||||||
@@ -127,9 +111,7 @@ class Routes {
|
|||||||
$activity_log_controller->register_routes();
|
$activity_log_controller->register_routes();
|
||||||
|
|
||||||
// Products controller
|
// Products controller
|
||||||
error_log('WooNooW Routes: Registering ProductsController routes');
|
|
||||||
ProductsController::register_routes();
|
ProductsController::register_routes();
|
||||||
error_log('WooNooW Routes: ProductsController routes registered');
|
|
||||||
|
|
||||||
// Coupons controller
|
// Coupons controller
|
||||||
CouponsController::register_routes();
|
CouponsController::register_routes();
|
||||||
@@ -141,12 +123,11 @@ class Routes {
|
|||||||
NewsletterController::register_routes();
|
NewsletterController::register_routes();
|
||||||
|
|
||||||
// Frontend controllers (customer-facing)
|
// Frontend controllers (customer-facing)
|
||||||
error_log('WooNooW Routes: Registering Frontend controllers');
|
|
||||||
ShopController::register_routes();
|
ShopController::register_routes();
|
||||||
FrontendCartController::register_routes();
|
FrontendCartController::register_routes();
|
||||||
AccountController::register_routes();
|
AccountController::register_routes();
|
||||||
|
AddressController::register_routes();
|
||||||
HookBridge::register_routes();
|
HookBridge::register_routes();
|
||||||
error_log('WooNooW Routes: Frontend controllers registered');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CustomerSettingsProvider {
|
|||||||
return [
|
return [
|
||||||
// General
|
// General
|
||||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
'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 Customer Qualification
|
||||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
'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');
|
$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
|
// VIP settings
|
||||||
if (isset($settings['vip_min_spent'])) {
|
if (isset($settings['vip_min_spent'])) {
|
||||||
$updated = $updated && update_option('woonoow_vip_min_spent', floatval($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
|
// Address routes moved to AddressController
|
||||||
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'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get downloads (for digital products)
|
// Get downloads (for digital products)
|
||||||
register_rest_route($namespace, '/account/downloads', [
|
register_rest_route($namespace, '/account/downloads', [
|
||||||
@@ -330,34 +318,51 @@ class AccountController {
|
|||||||
* Format order data for API response
|
* Format order data for API response
|
||||||
*/
|
*/
|
||||||
private static function format_order($order, $detailed = false) {
|
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 = [
|
$data = [
|
||||||
'id' => $order->get_id(),
|
'id' => $order->get_id(),
|
||||||
'order_number' => $order->get_order_number(),
|
'order_number' => $order->get_order_number(),
|
||||||
'status' => $order->get_status(),
|
'status' => $order->get_status(),
|
||||||
'date_created' => $order->get_date_created()->date('Y-m-d H:i:s'),
|
'date' => $order->get_date_created()->date('Y-m-d H:i:s'),
|
||||||
'total' => $order->get_total(),
|
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
|
||||||
'currency' => $order->get_currency(),
|
'currency' => $order->get_currency(),
|
||||||
'payment_method' => $order->get_payment_method_title(),
|
'payment_method_title' => $payment_title,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($detailed) {
|
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();
|
$product = $item->get_product();
|
||||||
return [
|
return [
|
||||||
'id' => $item->get_id(),
|
'id' => $item->get_id(),
|
||||||
'name' => $item->get_name(),
|
'name' => $item->get_name(),
|
||||||
'quantity' => $item->get_quantity(),
|
'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()) : '',
|
'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['billing'] = $order->get_address('billing');
|
||||||
$data['shipping'] = $order->get_address('shipping');
|
$data['shipping'] = $order->get_address('shipping');
|
||||||
$data['subtotal'] = $order->get_subtotal();
|
$data['needs_shipping'] = $needs_shipping;
|
||||||
$data['shipping_total'] = $order->get_shipping_total();
|
$data['subtotal'] = html_entity_decode(strip_tags(wc_price($order->get_subtotal())));
|
||||||
$data['tax_total'] = $order->get_total_tax();
|
$data['shipping_total'] = html_entity_decode(strip_tags(wc_price($order->get_shipping_total())));
|
||||||
$data['discount_total'] = $order->get_discount_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;
|
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
|
// Check if this is a cart endpoint
|
||||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
if (strpos($request_uri, '/woonoow/v1/cart') !== false) {
|
if (strpos($request_uri, '/woonoow/v1/cart') !== false) {
|
||||||
error_log('WooNooW Cart: Bypassing authentication for cart endpoint');
|
|
||||||
return true; // Allow access
|
return true; // Allow access
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ class CartController {
|
|||||||
* Register REST API routes
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes() {
|
||||||
error_log('WooNooW CartController::register_routes() START');
|
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
// Get cart
|
// Get cart
|
||||||
@@ -46,7 +44,6 @@ class CartController {
|
|||||||
'callback' => [__CLASS__, 'get_cart'],
|
'callback' => [__CLASS__, 'get_cart'],
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
error_log('WooNooW CartController: GET /cart registered: ' . ($result ? 'SUCCESS' : 'FAILED'));
|
|
||||||
|
|
||||||
// Add to cart
|
// Add to cart
|
||||||
$result = register_rest_route($namespace, '/cart/add', [
|
$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
|
// Update cart item
|
||||||
register_rest_route($namespace, '/cart/update', [
|
register_rest_route($namespace, '/cart/update', [
|
||||||
|
|||||||
Reference in New Issue
Block a user