feat: implement multiple saved addresses with modal selector in checkout

- Add AddressController with full CRUD API for saved addresses
- Implement address management UI in My Account > Addresses
- Add modal-based address selector in checkout (Tokopedia-style)
- Hide checkout forms when saved address is selected
- Add search functionality in address modal
- Auto-select default addresses on page load
- Fix variable products to show 'Select Options' instead of 'Add to Cart'
- Add admin toggle for multiple addresses feature
- Clean up debug logs and fix TypeScript errors
This commit is contained in:
Dwindi Ramadhana
2025-12-26 01:16:11 +07:00
parent 9ac09582d2
commit 100f9cce55
27 changed files with 2492 additions and 205 deletions

312
MY_ACCOUNT_PLAN.md Normal file
View 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

View File

@@ -63,10 +63,10 @@ export default function AppearanceHeader() {
style,
sticky,
height,
mobile_menu: mobileMenu,
mobile_logo: mobileLogo,
logo_width: logoWidth,
logo_height: logoHeight,
mobileMenu,
mobileLogo,
logoWidth,
logoHeight,
elements,
});
toast.success('Header settings saved successfully');

View File

@@ -11,7 +11,11 @@ import { api } from '@/lib/api';
export default function AppearanceShop() {
const [loading, setLoading] = useState(true);
const [gridColumns, setGridColumns] = useState('3');
const [gridColumns, setGridColumns] = useState({
mobile: '2',
tablet: '3',
desktop: '4'
});
const [gridStyle, setGridStyle] = useState('standard');
const [cardStyle, setCardStyle] = useState('card');
const [aspectRatio, setAspectRatio] = useState('square');
@@ -37,7 +41,11 @@ export default function AppearanceShop() {
const shop = response.data?.pages?.shop;
if (shop) {
setGridColumns(shop.layout?.grid_columns || '3');
setGridColumns(shop.layout?.grid_columns || {
mobile: '2',
tablet: '3',
desktop: '4'
});
setGridStyle(shop.layout?.grid_style || 'standard');
setCardStyle(shop.layout?.card_style || 'card');
setAspectRatio(shop.layout?.aspect_ratio || 'square');
@@ -110,17 +118,55 @@ export default function AppearanceShop() {
title="Layout"
description="Configure shop page layout and product display"
>
<SettingsSection label="Grid Columns" htmlFor="grid-columns">
<Select value={gridColumns} onValueChange={setGridColumns}>
<SelectTrigger id="grid-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2 Columns</SelectItem>
<SelectItem value="3">3 Columns</SelectItem>
<SelectItem value="4">4 Columns</SelectItem>
</SelectContent>
</Select>
<SettingsSection label="Grid Columns" description="Set columns for each breakpoint">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="grid-columns-mobile" className="text-sm font-medium mb-2 block">Mobile</Label>
<Select value={gridColumns.mobile} onValueChange={(value) => setGridColumns({ ...gridColumns, mobile: value })}>
<SelectTrigger id="grid-columns-mobile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">&lt;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">&gt;1024px</p>
</div>
</div>
</SettingsSection>
<SettingsSection label="Grid Style" htmlFor="grid-style" description="Masonry creates a Pinterest-like layout with varying heights">

View File

@@ -12,6 +12,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
interface CustomerSettings {
auto_register_members: boolean;
multiple_addresses_enabled: boolean;
vip_min_spent: number;
vip_min_orders: number;
vip_timeframe: 'all' | '30' | '90' | '365';
@@ -22,6 +23,7 @@ interface CustomerSettings {
export default function CustomersSettings() {
const [settings, setSettings] = useState<CustomerSettings>({
auto_register_members: false,
multiple_addresses_enabled: true,
vip_min_spent: 1000,
vip_min_orders: 10,
vip_timeframe: 'all',
@@ -119,13 +121,23 @@ export default function CustomersSettings() {
title={__('General')}
description={__('General customer settings')}
>
<ToggleField
id="auto_register_members"
label={__('Auto-register customers as site members')}
description={__('Automatically create WordPress user accounts for new customers when orders are created. Customers will receive login credentials via email and can track their orders.')}
checked={settings.auto_register_members}
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
/>
<div className="space-y-6">
<ToggleField
id="auto_register_members"
label={__('Auto-register customers as site members')}
description={__('Automatically create WordPress user accounts for new customers when orders are created. Customers will receive login credentials via email and can track their orders.')}
checked={settings.auto_register_members}
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
/>
<ToggleField
id="multiple_addresses_enabled"
label={__('Enable multiple saved addresses')}
description={__('Allow customers to save multiple billing and shipping addresses in their account. Customers can select from saved addresses during checkout for faster ordering.')}
checked={settings.multiple_addresses_enabled}
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
/>
</div>
</SettingsCard>
<SettingsCard

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/lib/api/client';
export default function AccountDetails() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
});
const [passwordData, setPasswordData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const data = await api.get<any>('/account/profile');
setFormData({
firstName: data.first_name || '',
lastName: data.last_name || '',
email: data.email || '',
});
} catch (error) {
console.error('Load profile error:', error);
toast.error('Failed to load profile data');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await api.post('/account/profile', {
first_name: formData.firstName,
last_name: formData.lastName,
email: formData.email,
});
toast.success('Profile updated successfully');
} catch (error) {
console.error('Save profile error:', error);
toast.error('Failed to save profile');
} finally {
setSaving(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordData.newPassword !== passwordData.confirmPassword) {
toast.error('New passwords do not match');
return;
}
if (passwordData.newPassword.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
setSaving(true);
try {
await api.post('/account/password', {
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword,
});
toast.success('Password updated successfully');
setPasswordData({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
} catch (error: any) {
console.error('Password update error:', error);
toast.error(error.message || 'Failed to update password');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Account Details</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-2">First Name</label>
<input
id="firstName"
type="text"
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
disabled={saving}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-2">Last Name</label>
<input
id="lastName"
type="text"
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
disabled={saving}
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">Email Address</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
disabled={saving}
/>
</div>
<button
type="submit"
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</form>
{/* Password Change Form - Separate */}
<form onSubmit={handlePasswordChange} className="space-y-6 mt-8 pt-8 border-t">
<h2 className="text-xl font-semibold">Password Change</h2>
<div className="space-y-4">
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium mb-2">Current Password</label>
<input
id="currentPassword"
type="password"
value={passwordData.currentPassword}
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
disabled={saving}
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium mb-2">New Password</label>
<input
id="newPassword"
type="password"
value={passwordData.newPassword}
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
disabled={saving}
/>
<p className="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2">Confirm New Password</label>
<input
id="confirmPassword"
type="password"
value={passwordData.confirmPassword}
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
disabled={saving}
/>
</div>
</div>
<button
type="submit"
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving}
>
{saving ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,442 @@
import React, { useState, useEffect } from 'react';
import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
interface Address {
id: number;
label: string;
type: 'billing' | 'shipping' | 'both';
first_name: string;
last_name: string;
company?: string;
address_1: string;
address_2?: string;
city: string;
state: string;
postcode: string;
country: string;
email?: string;
phone?: string;
is_default: boolean;
}
export default function Addresses() {
const [addresses, setAddresses] = useState<Address[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const [formData, setFormData] = useState<Partial<Address>>({
label: '',
type: 'both',
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: 'ID',
email: '',
phone: '',
is_default: false,
});
useEffect(() => {
loadAddresses();
}, []);
const loadAddresses = async () => {
try {
const response: any = await api.get('/account/addresses');
console.log('API response:', response);
console.log('Type of response:', typeof response);
console.log('Is array:', Array.isArray(response));
console.log('Response keys:', response ? Object.keys(response) : 'null');
console.log('Response values:', response ? Object.values(response) : 'null');
// Handle different response structures
let data: Address[] = [];
if (Array.isArray(response)) {
// Direct array response
data = response;
console.log('Using direct array');
} else if (response && typeof response === 'object') {
// Log all properties to debug
console.log('Checking object properties...');
// Check common wrapper properties
if (Array.isArray(response.data)) {
data = response.data;
console.log('Using response.data');
} else if (Array.isArray(response.addresses)) {
data = response.addresses;
console.log('Using response.addresses');
} else if (response.length !== undefined && typeof response === 'object') {
// Might be array-like object, convert to array
data = Object.values(response).filter((item: any) => item && typeof item === 'object' && item.id) as Address[];
console.log('Converted object to array:', data);
} else {
console.error('API returned unexpected structure:', response);
console.error('Available keys:', Object.keys(response));
}
}
console.log('Final addresses array:', data);
setAddresses(data);
} catch (error) {
console.error('Load addresses error:', error);
toast.error('Failed to load addresses');
setAddresses([]);
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setEditingAddress(null);
setFormData({
label: '',
type: 'both',
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: 'ID',
email: '',
phone: '',
is_default: false,
});
setShowModal(true);
};
const handleEdit = (address: Address) => {
setEditingAddress(address);
setFormData(address);
setShowModal(true);
};
const handleSave = async () => {
try {
console.log('Saving address:', formData);
if (editingAddress) {
console.log('Updating address ID:', editingAddress.id);
const response = await api.put(`/account/addresses/${editingAddress.id}`, formData);
console.log('Update response:', response);
toast.success('Address updated successfully');
} else {
console.log('Creating new address');
const response = await api.post('/account/addresses', formData);
console.log('Create response:', response);
toast.success('Address added successfully');
}
setShowModal(false);
loadAddresses();
} catch (error) {
console.error('Save address error:', error);
toast.error('Failed to save address');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this address?')) return;
try {
await api.delete(`/account/addresses/${id}`);
toast.success('Address deleted successfully');
loadAddresses();
} catch (error) {
console.error('Delete address error:', error);
toast.error('Failed to delete address');
}
};
const handleSetDefault = async (id: number) => {
try {
await api.post(`/account/addresses/${id}/set-default`, {});
toast.success('Default address updated');
loadAddresses();
} catch (error) {
console.error('Set default error:', error);
toast.error('Failed to set default address');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-600">Loading addresses...</div>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Addresses</h1>
<button
onClick={handleAdd}
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Address
</button>
</div>
{addresses.length === 0 ? (
<div className="text-center py-12 border rounded-lg">
<MapPin className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p className="text-gray-600 mb-4">No addresses saved yet</p>
<button
onClick={handleAdd}
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Your First Address
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{addresses.map((address) => (
<div key={address.id} className="border rounded-lg p-6 relative">
{address.is_default && (
<div className="absolute top-4 right-4">
<Star className="w-5 h-5 text-yellow-500 fill-yellow-500" />
</div>
)}
<div className="mb-4">
<h3 className="font-semibold text-lg">{address.label}</h3>
<span className="text-xs text-gray-500 uppercase">
{address.type === 'both' ? 'Billing & Shipping' : address.type}
</span>
</div>
<div className="text-sm text-gray-700 space-y-1 mb-4">
<p className="font-medium">{address.first_name} {address.last_name}</p>
{address.company && <p>{address.company}</p>}
<p>{address.address_1}</p>
{address.address_2 && <p>{address.address_2}</p>}
<p>{address.city}, {address.state} {address.postcode}</p>
<p>{address.country}</p>
{address.phone && <p className="pt-2">Phone: {address.phone}</p>}
{address.email && <p>Email: {address.email}</p>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(address)}
className="font-[inherit] flex items-center gap-1 text-sm text-primary hover:underline"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={() => handleDelete(address.id)}
className="font-[inherit] flex items-center gap-1 text-sm text-red-600 hover:underline"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
{!address.is_default && (
<button
onClick={() => handleSetDefault(address.id)}
className="font-[inherit] flex items-center gap-1 text-sm text-gray-600 hover:underline ml-auto"
>
<Star className="w-4 h-4" />
Set Default
</button>
)}
</div>
</div>
))}
</div>
)}
{/* Address Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold mb-4">
{editingAddress ? 'Edit Address' : 'Add New Address'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Label *</label>
<input
type="text"
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Home, Office, Parents"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Type *</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="both">Billing & Shipping</option>
<option value="billing">Billing Only</option>
<option value="shipping">Shipping Only</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name *</label>
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name *</label>
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Company</label>
<input
type="text"
value={formData.company}
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Line 1 *</label>
<input
type="text"
value={formData.address_1}
onChange={(e) => setFormData({ ...formData, address_1: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Line 2</label>
<input
type="text"
value={formData.address_2}
onChange={(e) => setFormData({ ...formData, address_2: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">City *</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">State/Province *</label>
<input
type="text"
value={formData.state}
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Postcode *</label>
<input
type="text"
value={formData.postcode}
onChange={(e) => setFormData({ ...formData, postcode: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Country *</label>
<input
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_default"
checked={formData.is_default}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="is_default" className="text-sm">Set as default address</label>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={handleSave}
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Save Address
</button>
<button
onClick={() => setShowModal(false)}
className="font-[inherit] px-4 py-2 border rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ShoppingBag, Package, MapPin, User } from 'lucide-react';
export default function Dashboard() {
const user = (window as any).woonoowCustomer?.user;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
Hello {user?.display_name || 'there'}!
</h1>
<p className="text-gray-600 mb-8">
From your account dashboard you can view your recent orders, manage your shipping and billing addresses, and edit your password and account details.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
to="/my-account/orders"
className="p-6 border rounded-lg hover:border-primary transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<ShoppingBag className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="font-semibold">Orders</h3>
<p className="text-sm text-gray-600">View your order history</p>
</div>
</div>
</Link>
<Link
to="/my-account/downloads"
className="p-6 border rounded-lg hover:border-primary transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="font-semibold">Downloads</h3>
<p className="text-sm text-gray-600">Access your downloads</p>
</div>
</div>
</Link>
<Link
to="/my-account/addresses"
className="p-6 border rounded-lg hover:border-primary transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<MapPin className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="font-semibold">Addresses</h3>
<p className="text-sm text-gray-600">Manage your addresses</p>
</div>
</div>
</Link>
<Link
to="/my-account/account-details"
className="p-6 border rounded-lg hover:border-primary transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<User className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="font-semibold">Account Details</h3>
<p className="text-sm text-gray-600">Edit your information</p>
</div>
</div>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { Download } from 'lucide-react';
export default function Downloads() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
<div className="text-center py-12">
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600">No downloads available</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
interface OrderItem {
id: number;
name: string;
quantity: number;
total: string;
image?: string;
}
interface Order {
id: number;
order_number: string;
date: string;
status: string;
total: string;
subtotal: string;
shipping_total: string;
tax_total: string;
items: OrderItem[];
billing: any;
shipping: any;
payment_method_title: string;
needs_shipping: boolean;
}
export default function OrderDetails() {
const { orderId } = useParams();
const [loading, setLoading] = useState(true);
const [order, setOrder] = useState<Order | null>(null);
useEffect(() => {
if (orderId) {
loadOrder();
}
}, [orderId]);
const loadOrder = async () => {
try {
const data = await api.get<Order>(`/account/orders/${orderId}`);
setOrder(data);
} catch (error) {
console.error('Load order error:', error);
toast.error('Failed to load order details');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
'completed': 'bg-green-100 text-green-800',
'processing': 'bg-blue-100 text-blue-800',
'pending': 'bg-yellow-100 text-yellow-800',
'on-hold': 'bg-orange-100 text-orange-800',
'cancelled': 'bg-red-100 text-red-800',
'refunded': 'bg-gray-100 text-gray-800',
'failed': 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-600">Loading order...</div>
</div>
);
}
if (!order) {
return (
<div>
<Link to="/my-account/orders" className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
<ArrowLeft className="w-4 h-4" />
Back to Orders
</Link>
<div className="text-center py-12">
<p className="text-gray-600">Order not found</p>
</div>
</div>
);
}
return (
<div>
<Link to="/my-account/orders" className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
<ArrowLeft className="w-4 h-4" />
Back to Orders
</Link>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
{order.status.replace('-', ' ').toUpperCase()}
</span>
</div>
<div className="text-sm text-gray-600 mb-6">
Placed on {formatDate(order.date)}
</div>
{/* Order Items */}
<div className="border rounded-lg mb-6">
<div className="bg-gray-50 px-4 py-3 border-b">
<h2 className="text-base font-medium">Order Items</h2>
</div>
<div className="divide-y">
{(order.items || []).map((item) => (
<div key={item.id} className="p-4 flex items-center gap-4">
{item.image && (
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded" />
)}
<div className="flex-1">
<h3 className="font-medium">{item.name}</h3>
<p className="text-sm text-gray-600">Quantity: {item.quantity}</p>
</div>
<div className="font-semibold">{item.total}</div>
</div>
))}
</div>
</div>
{/* Order Summary */}
<div className="border rounded-lg mb-6">
<div className="bg-gray-50 px-4 py-3 border-b">
<h2 className="text-base font-medium">Order Summary</h2>
</div>
<div className="p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Subtotal</span>
<span>{order.subtotal}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Shipping</span>
<span>{order.shipping_total}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Tax</span>
<span>{order.tax_total}</span>
</div>
<div className="flex justify-between font-bold text-lg pt-2 border-t">
<span>Total</span>
<span>{order.total}</span>
</div>
</div>
</div>
{/* Addresses */}
<div className={`grid grid-cols-1 gap-6 ${order.needs_shipping ? 'md:grid-cols-2' : ''}`}>
<div className="border rounded-lg">
<div className="bg-gray-50 px-4 py-3 border-b">
<h2 className="text-base font-medium">Billing Address</h2>
</div>
<div className="p-4 text-sm">
{order.billing && (
<div className="space-y-1">
<p className="font-medium">{order.billing.first_name} {order.billing.last_name}</p>
{order.billing.company && <p>{order.billing.company}</p>}
<p>{order.billing.address_1}</p>
{order.billing.address_2 && <p>{order.billing.address_2}</p>}
<p>{order.billing.city}, {order.billing.state} {order.billing.postcode}</p>
<p>{order.billing.country}</p>
{order.billing.email && <p className="pt-2">Email: {order.billing.email}</p>}
{order.billing.phone && <p>Phone: {order.billing.phone}</p>}
</div>
)}
</div>
</div>
{/* Only show shipping address if order needs shipping (not virtual-only) */}
{order.needs_shipping && (
<div className="border rounded-lg">
<div className="bg-gray-50 px-4 py-3 border-b">
<h2 className="text-base font-medium">Shipping Address</h2>
</div>
<div className="p-4 text-sm">
{order.shipping && (
<div className="space-y-1">
<p className="font-medium">{order.shipping.first_name} {order.shipping.last_name}</p>
{order.shipping.company && <p>{order.shipping.company}</p>}
<p>{order.shipping.address_1}</p>
{order.shipping.address_2 && <p>{order.shipping.address_2}</p>}
<p>{order.shipping.city}, {order.shipping.state} {order.shipping.postcode}</p>
<p>{order.shipping.country}</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Payment Method */}
<div className="mt-6 border rounded-lg">
<div className="bg-gray-50 px-4 py-3 border-b">
<h2 className="text-base font-medium">Payment Method</h2>
</div>
<div className="p-4 text-sm">
{order.payment_method_title || 'Not specified'}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Package, Eye } from 'lucide-react';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
interface Order {
id: number;
order_number: string;
date: string;
status: string;
total: string;
items_count: number;
}
export default function Orders() {
const [loading, setLoading] = useState(true);
const [orders, setOrders] = useState<Order[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
loadOrders();
}, [page]);
const loadOrders = async () => {
try {
const data = await api.get<any>('/account/orders', { page, per_page: 10 });
setOrders(data.orders || []);
setTotalPages(data.total_pages || 1);
} catch (error) {
console.error('Load orders error:', error);
toast.error('Failed to load orders');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
'completed': 'bg-green-100 text-green-800',
'processing': 'bg-blue-100 text-blue-800',
'pending': 'bg-yellow-100 text-yellow-800',
'on-hold': 'bg-orange-100 text-orange-800',
'cancelled': 'bg-red-100 text-red-800',
'refunded': 'bg-gray-100 text-gray-800',
'failed': 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-600">Loading orders...</div>
</div>
);
}
if (orders.length === 0) {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Orders</h1>
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No orders yet</p>
<Link
to="/shop"
className="inline-block px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
Browse Products
</Link>
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Orders</h1>
<div className="space-y-4">
{orders.map((order) => (
<div key={order.id} className="border rounded-lg p-4 hover:border-primary transition-colors">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-lg">Order #{order.order_number}</h3>
<p className="text-sm text-gray-600">{formatDate(order.date)}</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
{order.status.replace('-', ' ').toUpperCase()}
</span>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
{order.items_count} {order.items_count === 1 ? 'item' : 'items'}
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-lg">{order.total}</span>
<Link
to={`/my-account/orders/${order.id}`}
className="flex items-center gap-2 px-4 py-2 border border-primary text-primary rounded-lg hover:bg-primary hover:text-white transition-colors"
>
<Eye className="w-4 h-4" />
View
</Link>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="font-[inherit] px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="font-[inherit] px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import React, { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, ShoppingBag, Download, MapPin, User, LogOut } from 'lucide-react';
interface AccountLayoutProps {
children: ReactNode;
}
export function AccountLayout({ children }: AccountLayoutProps) {
const location = useLocation();
const user = (window as any).woonoowCustomer?.user;
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
];
const handleLogout = () => {
window.location.href = '/wp-login.php?action=logout';
};
const isActive = (path: string) => {
if (path === '/my-account') {
return location.pathname === '/my-account';
}
return location.pathname.startsWith(path);
};
// Sidebar Navigation
const SidebarNav = () => (
<aside className="bg-white rounded-lg border p-4">
<div className="mb-6">
<div className="flex items-center gap-3 pb-4 border-b">
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<User className="w-6 h-6 text-gray-600" />
</div>
<div>
<p className="font-semibold">{user?.display_name || 'User'}</p>
<p className="text-sm text-gray-500">{user?.email}</p>
</div>
</div>
</div>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.id}
to={item.path}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
isActive(item.path)
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</Link>
);
})}
<button
onClick={handleLogout}
className="w-full font-[inherit] flex items-center gap-3 px-4 py-2.5 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">Logout</span>
</button>
</nav>
</aside>
);
// Tab Navigation (Mobile)
const TabNav = () => (
<div className="bg-white rounded-lg border mb-6 lg:hidden">
<nav className="flex overflow-x-auto">
{menuItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.id}
to={item.path}
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${
isActive(item.path)
? 'border-primary text-primary font-medium'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<Icon className="w-5 h-5" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</div>
);
// Responsive layout: Tabs on mobile, Sidebar on desktop
return (
<div className="py-8">
{/* Mobile: Tab Navigation */}
<TabNav />
{/* Desktop: Sidebar + Content */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="hidden lg:block lg:col-span-1">
<SidebarNav />
</div>
<div className="lg:col-span-3">
<div className="bg-white rounded-lg border p-6">
{children}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,70 +1,36 @@
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
function Dashboard() {
return (
<div>
<h2 className="text-2xl font-bold mb-4">My Account</h2>
<p className="text-muted-foreground">Welcome to your account dashboard.</p>
</div>
);
}
function Orders() {
return (
<div>
<h2 className="text-2xl font-bold mb-4">Orders</h2>
<p className="text-muted-foreground">Your order history will appear here.</p>
</div>
);
}
function Profile() {
return (
<div>
<h2 className="text-2xl font-bold mb-4">Profile</h2>
<p className="text-muted-foreground">Edit your profile information.</p>
</div>
);
}
import { Routes, Route, Navigate } from 'react-router-dom';
import Container from '@/components/Layout/Container';
import { AccountLayout } from './components/AccountLayout';
import Dashboard from './Dashboard';
import Orders from './Orders';
import OrderDetails from './OrderDetails';
import Downloads from './Downloads';
import Addresses from './Addresses';
import AccountDetails from './AccountDetails';
export default function Account() {
const user = (window as any).woonoowCustomer?.user;
// Redirect to login if not authenticated
if (!user?.isLoggedIn) {
window.location.href = '/wp-login.php?redirect_to=' + encodeURIComponent(window.location.href);
return null;
}
return (
<div className="container-safe py-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="md:col-span-1">
<nav className="space-y-2">
<Link
to="/account"
className="block px-4 py-2 rounded-lg hover:bg-accent"
>
Dashboard
</Link>
<Link
to="/account/orders"
className="block px-4 py-2 rounded-lg hover:bg-accent"
>
Orders
</Link>
<Link
to="/account/profile"
className="block px-4 py-2 rounded-lg hover:bg-accent"
>
Profile
</Link>
</nav>
</aside>
{/* Main Content */}
<main className="md:col-span-3">
<Routes>
<Route index element={<Dashboard />} />
<Route path="orders" element={<Orders />} />
<Route path="profile" element={<Profile />} />
</Routes>
</main>
</div>
</div>
<Container>
<AccountLayout>
<Routes>
<Route index element={<Dashboard />} />
<Route path="orders" element={<Orders />} />
<Route path="orders/:orderId" element={<OrderDetails />} />
<Route path="downloads" element={<Downloads />} />
<Route path="addresses" element={<Addresses />} />
<Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes>
</AccountLayout>
</Container>
);
}

View File

@@ -126,7 +126,7 @@ export default function Cart() {
<div className="flex items-center gap-2">
<button
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
className="p-1 hover:bg-gray-100 rounded"
className="font-[inherit] p-1 hover:bg-gray-100 rounded"
>
<Minus className="h-4 w-4" />
</button>
@@ -141,7 +141,7 @@ export default function Cart() {
/>
<button
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
className="p-1 hover:bg-gray-100 rounded"
className="font-[inherit] p-1 hover:bg-gray-100 rounded"
>
<Plus className="h-4 w-4" />
</button>
@@ -152,7 +152,7 @@ export default function Cart() {
<div className="flex flex-col items-end justify-between">
<button
onClick={() => handleRemoveItem(item.key)}
className="text-red-600 hover:text-red-700 p-2"
className="font-[inherit] text-red-600 hover:text-red-700 p-2"
>
<Trash2 className="h-5 w-5" />
</button>

View File

@@ -5,9 +5,29 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
import { Button } from '@/components/ui/button';
import Container from '@/components/Layout/Container';
import { formatPrice } from '@/lib/currency';
import { ArrowLeft, ShoppingBag } from 'lucide-react';
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2 } from 'lucide-react';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
import { api } from '@/lib/api/client';
import { AddressSelector } from '@/components/AddressSelector';
interface SavedAddress {
id: number;
label: string;
type: 'billing' | 'shipping' | 'both';
first_name: string;
last_name: string;
company?: string;
address_1: string;
address_2?: string;
city: string;
state: string;
postcode: string;
country: string;
email?: string;
phone?: string;
is_default: boolean;
}
export default function Checkout() {
const navigate = useNavigate();
@@ -54,10 +74,95 @@ export default function Checkout() {
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
const [orderNotes, setOrderNotes] = useState('');
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
// Saved addresses
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<number | null>(null);
const [selectedShippingAddressId, setSelectedShippingAddressId] = useState<number | null>(null);
const [loadingAddresses, setLoadingAddresses] = useState(true);
const [showBillingModal, setShowBillingModal] = useState(false);
const [showShippingModal, setShowShippingModal] = useState(false);
const [showBillingForm, setShowBillingForm] = useState(true);
const [showShippingForm, setShowShippingForm] = useState(true);
// Load saved addresses
useEffect(() => {
const loadAddresses = async () => {
if (!user?.isLoggedIn) {
setLoadingAddresses(false);
return;
}
try {
const addresses = await api.get<SavedAddress[]>('/account/addresses');
setSavedAddresses(addresses);
// Auto-select default addresses
const defaultBilling = addresses.find(a => a.is_default && (a.type === 'billing' || a.type === 'both'));
const defaultShipping = addresses.find(a => a.is_default && (a.type === 'shipping' || a.type === 'both'));
if (defaultBilling) {
setSelectedBillingAddressId(defaultBilling.id);
fillBillingFromAddress(defaultBilling);
setShowBillingForm(false); // Hide form when default address is auto-selected
}
if (defaultShipping && !isVirtualOnly) {
setSelectedShippingAddressId(defaultShipping.id);
fillShippingFromAddress(defaultShipping);
setShowShippingForm(false); // Hide form when default address is auto-selected
}
} catch (error) {
console.error('Failed to load addresses:', error);
} finally {
setLoadingAddresses(false);
}
};
loadAddresses();
}, [user, isVirtualOnly]);
// Helper functions to fill forms from saved addresses
const fillBillingFromAddress = (address: SavedAddress) => {
setBillingData({
firstName: address.first_name,
lastName: address.last_name,
email: address.email || billingData.email,
phone: address.phone || billingData.phone,
address: address.address_1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
});
};
const fillShippingFromAddress = (address: SavedAddress) => {
setShippingData({
firstName: address.first_name,
lastName: address.last_name,
address: address.address_1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
});
};
const handleSelectBillingAddress = (address: SavedAddress) => {
setSelectedBillingAddressId(address.id);
fillBillingFromAddress(address);
setShowBillingForm(false); // Hide form when address is selected
};
const handleSelectShippingAddress = (address: SavedAddress) => {
setSelectedShippingAddressId(address.id);
fillShippingFromAddress(address);
setShowShippingForm(false); // Hide form when address is selected
};
// Auto-fill form with user data if logged in
useEffect(() => {
if (user?.isLoggedIn && user?.billing) {
if (user?.isLoggedIn && user?.billing && savedAddresses.length === 0) {
setBillingData({
firstName: user.billing.first_name || '',
lastName: user.billing.last_name || '',
@@ -183,7 +288,75 @@ export default function Checkout() {
<div className={`grid gap-8 ${layout.style === 'single-column' ? 'grid-cols-1' : layout.order_summary === 'top' ? 'grid-cols-1' : 'lg:grid-cols-3'}`}>
{/* Billing & Shipping Forms */}
<div className={`space-y-6 ${layout.style === 'single-column' || layout.order_summary === 'top' ? '' : 'lg:col-span-2'}`}>
{/* Billing Details */}
{/* Selected Billing Address Summary */}
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'billing' || a.type === 'both') && (
<div className="bg-white border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<MapPin className="w-5 h-5" />
Billing Address
</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowBillingModal(true)}
className="flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Change Address
</Button>
</div>
{selectedBillingAddressId ? (
(() => {
const selected = savedAddresses.find(a => a.id === selectedBillingAddressId);
return selected ? (
<div>
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold">{selected.label}</p>
{selected.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
)}
</div>
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
<p className="text-sm text-gray-600">{selected.country}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBillingForm(true)}
className="mt-3 text-primary hover:text-primary"
>
Use a different address
</Button>
</div>
) : null;
})()
) : (
<p className="text-gray-500 text-sm">No address selected</p>
)}
</div>
)}
{/* Billing Address Modal */}
<AddressSelector
isOpen={showBillingModal}
onClose={() => setShowBillingModal(false)}
addresses={savedAddresses}
selectedAddressId={selectedBillingAddressId}
onSelectAddress={handleSelectBillingAddress}
type="billing"
/>
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
<div className="bg-white border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -285,6 +458,7 @@ export default function Checkout() {
)}
</div>
</div>
)}
{/* Ship to Different Address - only for physical products */}
{!isVirtualOnly && (
@@ -300,7 +474,77 @@ export default function Checkout() {
</label>
{shipToDifferentAddress && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<>
{/* Selected Shipping Address Summary */}
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MapPin className="w-4 h-4" />
Shipping Address
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowShippingModal(true)}
className="flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Change Address
</Button>
</div>
{selectedShippingAddressId ? (
(() => {
const selected = savedAddresses.find(a => a.id === selectedShippingAddressId);
return selected ? (
<div>
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold">{selected.label}</p>
{selected.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
)}
</div>
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
<p className="text-sm text-gray-600">{selected.country}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowShippingForm(true)}
className="mt-3 text-primary hover:text-primary"
>
Use a different address
</Button>
</div>
) : null;
})()
) : (
<p className="text-gray-500 text-sm">No address selected</p>
)}
</div>
)}
{/* Shipping Address Modal */}
<AddressSelector
isOpen={showShippingModal}
onClose={() => setShowShippingModal(false)}
addresses={savedAddresses}
selectedAddressId={selectedShippingAddressId}
onSelectAddress={handleSelectShippingAddress}
type="shipping"
/>
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
{(!selectedShippingAddressId || showShippingForm) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">First Name *</label>
<input
@@ -372,6 +616,8 @@ export default function Checkout() {
/>
</div>
</div>
)}
</>
)}
</div>
)}

View File

@@ -23,21 +23,56 @@ export default function Shop() {
const [sortBy, setSortBy] = useState('');
const { addItem } = useCartStore();
// Map grid columns setting to Tailwind classes
const gridColsClass = {
'2': 'grid-cols-1 sm:grid-cols-2',
'3': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
'4': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
'5': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
'6': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6',
}[shopLayout.grid_columns] || 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
// Map grid columns setting to Tailwind classes (responsive)
const gridCols = typeof shopLayout.grid_columns === 'object'
? shopLayout.grid_columns
: { mobile: '2', tablet: '3', desktop: '4' };
// Masonry column classes (CSS columns)
const masonryColsClass = {
'2': 'columns-1 sm:columns-2',
'3': 'columns-1 sm:columns-2 lg:columns-3',
'4': 'columns-1 sm:columns-2 lg:columns-3 xl:columns-4',
}[shopLayout.grid_columns] || 'columns-1 sm:columns-2 lg:columns-3';
// Map to actual Tailwind classes (can't use template literals due to purging)
const mobileClass = {
'1': 'grid-cols-1',
'2': 'grid-cols-2',
'3': 'grid-cols-3',
}[gridCols.mobile] || 'grid-cols-2';
const tabletClass = {
'2': 'md:grid-cols-2',
'3': 'md:grid-cols-3',
'4': 'md:grid-cols-4',
}[gridCols.tablet] || 'md:grid-cols-3';
const desktopClass = {
'2': 'lg:grid-cols-2',
'3': 'lg:grid-cols-3',
'4': 'lg:grid-cols-4',
'5': 'lg:grid-cols-5',
'6': 'lg:grid-cols-6',
}[gridCols.desktop] || 'lg:grid-cols-4';
const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`;
// Masonry column classes
const masonryMobileClass = {
'1': 'columns-1',
'2': 'columns-2',
'3': 'columns-3',
}[gridCols.mobile] || 'columns-2';
const masonryTabletClass = {
'2': 'md:columns-2',
'3': 'md:columns-3',
'4': 'md:columns-4',
}[gridCols.tablet] || 'md:columns-3';
const masonryDesktopClass = {
'2': 'lg:columns-2',
'3': 'lg:columns-3',
'4': 'lg:columns-4',
'5': 'lg:columns-5',
'6': 'lg:columns-6',
}[gridCols.desktop] || 'lg:columns-4';
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
const isMasonry = shopLayout.grid_style === 'masonry';
@@ -114,7 +149,7 @@ export default function Shop() {
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>

View File

@@ -158,6 +158,37 @@ class CheckoutController {
return ['error' => $order->get_error_message()];
}
// Set customer ID if user is logged in
if (is_user_logged_in()) {
$user_id = get_current_user_id();
$order->set_customer_id($user_id);
// Update user's billing information from checkout data
if (!empty($payload['billing'])) {
$billing = $payload['billing'];
// Update first name and last name
if (!empty($billing['first_name'])) {
update_user_meta($user_id, 'first_name', sanitize_text_field($billing['first_name']));
update_user_meta($user_id, 'billing_first_name', sanitize_text_field($billing['first_name']));
}
if (!empty($billing['last_name'])) {
update_user_meta($user_id, 'last_name', sanitize_text_field($billing['last_name']));
update_user_meta($user_id, 'billing_last_name', sanitize_text_field($billing['last_name']));
}
// Update billing phone
if (!empty($billing['phone'])) {
update_user_meta($user_id, 'billing_phone', sanitize_text_field($billing['phone']));
}
// Update billing email
if (!empty($billing['email'])) {
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
}
}
}
// Add items
foreach ($payload['items'] as $line) {
$product = $this->load_product($line);
@@ -352,7 +383,7 @@ class CheckoutController {
if (!WC()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); }
if (!WC()->cart) { WC()->cart = new \WC_Cart(); }
// Address context for taxes/shipping rules
// Address context for taxes/shipping rules - set temporarily without saving to user profile
$ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing'];
if (!empty($payload['billing'])) {
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
@@ -370,7 +401,8 @@ class CheckoutController {
}
}
}
WC()->customer->save();
// DO NOT save customer data - only use for quote calculation
// WC()->customer->save(); // REMOVED - should not update user profile during checkout
WC()->cart->empty_cart(true);
foreach ($payload['items'] as $line) {

View File

@@ -24,6 +24,7 @@ use WooNooW\Api\NewsletterController;
use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController;
use WooNooW\Frontend\AddressController;
use WooNooW\Frontend\HookBridge;
use WooNooW\Api\Controllers\SettingsController;
use WooNooW\Api\Controllers\CartController as ApiCartController;
@@ -38,20 +39,7 @@ class Routes {
// Initialize CartController auth bypass (must be before rest_api_init)
FrontendCartController::init();
// Log ALL REST API requests to debug routing
add_filter('rest_pre_dispatch', function($result, $server, $request) {
$route = $request->get_route();
$method = $request->get_method();
$result_type = is_null($result) ? 'NULL (will call handler)' : 'NON-NULL (handler bypassed!)';
error_log("WooNooW REST: {$method} {$route} - Result: {$result_type}");
if (!is_null($result)) {
error_log("WooNooW REST: BYPASSED! Result type: " . gettype($result));
}
return $result;
}, 10, 3);
add_action('rest_api_init', function () {
error_log('WooNooW Routes: rest_api_init hook fired');
$namespace = 'woonoow/v1';
// Auth endpoints (public - no permission check)
@@ -82,10 +70,6 @@ class Routes {
$settings_controller = new SettingsController();
$settings_controller->register_routes();
// Cart controller (API) - DISABLED: Using Frontend CartController instead to avoid route conflicts
// $api_cart_controller = new ApiCartController();
// $api_cart_controller->register_routes();
// Payments controller
$payments_controller = new PaymentsController();
$payments_controller->register_routes();
@@ -127,9 +111,7 @@ class Routes {
$activity_log_controller->register_routes();
// Products controller
error_log('WooNooW Routes: Registering ProductsController routes');
ProductsController::register_routes();
error_log('WooNooW Routes: ProductsController routes registered');
// Coupons controller
CouponsController::register_routes();
@@ -141,12 +123,11 @@ class Routes {
NewsletterController::register_routes();
// Frontend controllers (customer-facing)
error_log('WooNooW Routes: Registering Frontend controllers');
ShopController::register_routes();
FrontendCartController::register_routes();
AccountController::register_routes();
AddressController::register_routes();
HookBridge::register_routes();
error_log('WooNooW Routes: Frontend controllers registered');
});
}
}

View File

@@ -20,6 +20,7 @@ class CustomerSettingsProvider {
return [
// General
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
// VIP Customer Qualification
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
@@ -44,6 +45,10 @@ class CustomerSettingsProvider {
$updated = $updated && update_option('woonoow_auto_register_members', $settings['auto_register_members'] ? 'yes' : 'no');
}
if (isset($settings['multiple_addresses_enabled'])) {
$updated = $updated && update_option('woonoow_multiple_addresses_enabled', $settings['multiple_addresses_enabled'] ? 'yes' : 'no');
}
// VIP settings
if (isset($settings['vip_min_spent'])) {
$updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));

View File

@@ -79,19 +79,7 @@ class AccountController {
],
]);
// Get addresses
register_rest_route($namespace, '/account/addresses', [
[
'methods' => 'GET',
'callback' => [__CLASS__, 'get_addresses'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
],
[
'methods' => 'POST',
'callback' => [__CLASS__, 'update_addresses'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
],
]);
// Address routes moved to AddressController
// Get downloads (for digital products)
register_rest_route($namespace, '/account/downloads', [
@@ -330,34 +318,51 @@ class AccountController {
* Format order data for API response
*/
private static function format_order($order, $detailed = false) {
$payment_title = $order->get_payment_method_title();
if (empty($payment_title)) {
$payment_title = $order->get_payment_method() ?: 'Not specified';
}
$data = [
'id' => $order->get_id(),
'order_number' => $order->get_order_number(),
'status' => $order->get_status(),
'date_created' => $order->get_date_created()->date('Y-m-d H:i:s'),
'total' => $order->get_total(),
'date' => $order->get_date_created()->date('Y-m-d H:i:s'),
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
'currency' => $order->get_currency(),
'payment_method' => $order->get_payment_method_title(),
'payment_method_title' => $payment_title,
];
if ($detailed) {
$data['items'] = array_map(function($item) {
$items = $order->get_items();
$data['items'] = is_array($items) ? array_values(array_map(function($item) {
$product = $item->get_product();
return [
'id' => $item->get_id(),
'name' => $item->get_name(),
'quantity' => $item->get_quantity(),
'total' => $item->get_total(),
'total' => html_entity_decode(strip_tags(wc_price($item->get_total()))),
'image' => $product ? wp_get_attachment_url($product->get_image_id()) : '',
];
}, $order->get_items());
}, $items)) : [];
// Check if order needs shipping (not virtual-only)
$needs_shipping = false;
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && !$product->is_virtual()) {
$needs_shipping = true;
break;
}
}
$data['billing'] = $order->get_address('billing');
$data['shipping'] = $order->get_address('shipping');
$data['subtotal'] = $order->get_subtotal();
$data['shipping_total'] = $order->get_shipping_total();
$data['tax_total'] = $order->get_total_tax();
$data['discount_total'] = $order->get_discount_total();
$data['needs_shipping'] = $needs_shipping;
$data['subtotal'] = html_entity_decode(strip_tags(wc_price($order->get_subtotal())));
$data['shipping_total'] = html_entity_decode(strip_tags(wc_price($order->get_shipping_total())));
$data['tax_total'] = html_entity_decode(strip_tags(wc_price($order->get_total_tax())));
$data['discount_total'] = html_entity_decode(strip_tags(wc_price($order->get_discount_total())));
}
return $data;

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

View File

@@ -25,7 +25,6 @@ class CartController {
// Check if this is a cart endpoint
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($request_uri, '/woonoow/v1/cart') !== false) {
error_log('WooNooW Cart: Bypassing authentication for cart endpoint');
return true; // Allow access
}
@@ -37,7 +36,6 @@ class CartController {
* Register REST API routes
*/
public static function register_routes() {
error_log('WooNooW CartController::register_routes() START');
$namespace = 'woonoow/v1';
// Get cart
@@ -46,7 +44,6 @@ class CartController {
'callback' => [__CLASS__, 'get_cart'],
'permission_callback' => '__return_true',
]);
error_log('WooNooW CartController: GET /cart registered: ' . ($result ? 'SUCCESS' : 'FAILED'));
// Add to cart
$result = register_rest_route($namespace, '/cart/add', [
@@ -73,7 +70,6 @@ class CartController {
],
],
]);
error_log('WooNooW CartController: POST /cart/add registered: ' . ($result ? 'SUCCESS' : 'FAILED'));
// Update cart item
register_rest_route($namespace, '/cart/update', [