Compare commits
86 Commits
07020bc0dd
...
v1.0-pre-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0421e5010f | ||
|
|
da6255dd0c | ||
|
|
91ae4956e0 | ||
|
|
b010a88619 | ||
|
|
a98217897c | ||
|
|
316fcbf2f0 | ||
|
|
3f8d15de61 | ||
|
|
930e525421 | ||
|
|
802b64db9f | ||
|
|
8959af8270 | ||
|
|
1ce99e2bb6 | ||
|
|
0a33ba0401 | ||
|
|
2ce7c0b263 | ||
|
|
47f6370ce0 | ||
|
|
47a1e78eb7 | ||
|
|
1af1add5d4 | ||
|
|
6bd50c1659 | ||
|
|
5a831ddf9d | ||
|
|
70006beeb9 | ||
|
|
e84fa969bb | ||
|
|
ccdd88a629 | ||
|
|
b8f179a984 | ||
|
|
78d7bc1161 | ||
|
|
62f25b624b | ||
|
|
10b3c0e47f | ||
|
|
508ec682a7 | ||
|
|
c83ea78911 | ||
|
|
58681e272e | ||
|
|
38a7a4ee23 | ||
|
|
875ab7af34 | ||
|
|
861c45638b | ||
|
|
8bd2713385 | ||
|
|
9671c7255a | ||
|
|
52cea87078 | ||
|
|
e9e54f52a7 | ||
|
|
4fcc69bfcd | ||
|
|
56042d4b8e | ||
|
|
3d7eb5bf48 | ||
|
|
f97cca8061 | ||
|
|
f79938c5be | ||
|
|
0dd7c7af70 | ||
|
|
285589937a | ||
|
|
a87357d890 | ||
|
|
d7505252ac | ||
|
|
3d5191aab3 | ||
|
|
65dd847a66 | ||
|
|
2dbc43a4eb | ||
|
|
771c48e4bb | ||
|
|
4104c6d6ba | ||
|
|
82399d4ddf | ||
|
|
93523a74ac | ||
|
|
2c4050451c | ||
|
|
fe98e6233d | ||
|
|
f054a78c5d | ||
|
|
012effd11d | ||
|
|
48a5a5593b | ||
|
|
e0777c708b | ||
|
|
b2ac2996f9 | ||
|
|
c8ce892d15 | ||
|
|
b6a0a66000 | ||
|
|
3260c8c112 | ||
|
|
0609c6e3d8 | ||
|
|
a5e5db827b | ||
|
|
447ca501c7 | ||
|
|
f1bab5ec46 | ||
|
|
8762c7d2c9 | ||
|
|
8093938e8b | ||
|
|
33e0f50238 | ||
|
|
ca3dd4aff3 | ||
|
|
70afb233cf | ||
|
|
8f61e39272 | ||
|
|
10acb58f6e | ||
|
|
e12c109270 | ||
|
|
4095d2a70c | ||
|
|
1c6b76efb4 | ||
|
|
9214172c79 | ||
|
|
e64045b0e1 | ||
|
|
0247f1edd8 | ||
|
|
c685c27b15 | ||
|
|
cc67288614 | ||
|
|
d575e12bf3 | ||
|
|
3aaee45981 | ||
|
|
863610043d | ||
|
|
9b8fa7d0f9 | ||
|
|
daebd5f989 | ||
|
|
c6cef97ef8 |
@@ -1,7 +1,7 @@
|
||||
# WooNooW Feature Roadmap - 2025
|
||||
|
||||
**Last Updated**: December 26, 2025
|
||||
**Status**: Planning Phase
|
||||
**Last Updated**: December 31, 2025
|
||||
**Status**: Active Development
|
||||
|
||||
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
||||
|
||||
@@ -22,11 +22,12 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
|
||||
- ✅ Newsletter Subscribers Management
|
||||
- ✅ Coupon System
|
||||
- ✅ Customer Wishlist (basic)
|
||||
- ✅ Product Reviews & Ratings
|
||||
- ✅ Module Management System (enable/disable features)
|
||||
- ✅ Admin SPA with modern UI
|
||||
- ✅ Customer SPA with theme system
|
||||
- ✅ REST API infrastructure
|
||||
- ✅ Addon bridge pattern
|
||||
- 🔲 Product Reviews & Ratings (not yet implemented)
|
||||
|
||||
---
|
||||
|
||||
@@ -35,7 +36,7 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
|
||||
### Overview
|
||||
Central control panel for enabling/disabling features to improve performance and reduce clutter.
|
||||
|
||||
### Status: **Planning** 🔵
|
||||
### Status: **Built** ✅
|
||||
|
||||
### Implementation
|
||||
|
||||
@@ -94,8 +95,8 @@ class ModuleRegistry {
|
||||
#### Navigation Integration
|
||||
Only show module routes if enabled in navigation tree.
|
||||
|
||||
### Priority: **High** 🔴
|
||||
### Effort: 1 week
|
||||
### Priority: ~~High~~ **Complete** ✅
|
||||
### Effort: ~~1 week~~ Done
|
||||
|
||||
---
|
||||
|
||||
|
||||
379
PHASE_2_3_4_SUMMARY.md
Normal file
379
PHASE_2_3_4_SUMMARY.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# Phase 2, 3, 4 Implementation Summary
|
||||
|
||||
**Date**: December 26, 2025
|
||||
**Status**: ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented the complete addon-module integration system with schema-based forms, custom React components, and a working example addon.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Schema-Based Form System ✅
|
||||
|
||||
### Backend Components
|
||||
|
||||
#### 1. **ModuleSettingsController.php** (NEW)
|
||||
- `GET /modules/{id}/settings` - Fetch module settings
|
||||
- `POST /modules/{id}/settings` - Save module settings
|
||||
- `GET /modules/{id}/schema` - Fetch settings schema
|
||||
- Automatic validation against schema
|
||||
- Action hooks: `woonoow/module_settings_updated/{module_id}`
|
||||
- Storage pattern: `woonoow_module_{module_id}_settings`
|
||||
|
||||
#### 2. **NewsletterSettings.php** (NEW)
|
||||
- Example implementation with 8 fields
|
||||
- Demonstrates all field types
|
||||
- Shows dynamic options (WordPress pages)
|
||||
- Registers schema via `woonoow/module_settings_schema` filter
|
||||
|
||||
### Frontend Components
|
||||
|
||||
#### 1. **SchemaField.tsx** (NEW)
|
||||
- Supports 8 field types: text, textarea, email, url, number, toggle, checkbox, select
|
||||
- Automatic validation (required, min/max)
|
||||
- Error display per field
|
||||
- Description and placeholder support
|
||||
|
||||
#### 2. **SchemaForm.tsx** (NEW)
|
||||
- Renders complete form from schema object
|
||||
- Manages form state
|
||||
- Submit handling with loading state
|
||||
- Error display integration
|
||||
|
||||
#### 3. **ModuleSettings.tsx** (NEW)
|
||||
- Generic settings page at `/settings/modules/:moduleId`
|
||||
- Auto-detects schema vs custom component
|
||||
- Fetches schema from API
|
||||
- Uses `useModuleSettings` hook
|
||||
- "Back to Modules" navigation
|
||||
|
||||
#### 4. **useModuleSettings.ts** (NEW)
|
||||
- React hook for settings management
|
||||
- Auto-invalidates queries on save
|
||||
- Toast notifications
|
||||
- `saveSetting(key, value)` helper
|
||||
|
||||
### Features Delivered
|
||||
|
||||
✅ No-code settings forms via schema
|
||||
✅ Automatic validation
|
||||
✅ Persistent storage
|
||||
✅ Newsletter example with 8 fields
|
||||
✅ Gear icon shows on modules with settings
|
||||
✅ Settings page auto-routes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Advanced Features ✅
|
||||
|
||||
### Window API Exposure
|
||||
|
||||
#### **windowAPI.ts** (NEW)
|
||||
Exposes comprehensive API to addon developers via `window.WooNooW`:
|
||||
|
||||
```typescript
|
||||
window.WooNooW = {
|
||||
React,
|
||||
ReactDOM,
|
||||
hooks: {
|
||||
useQuery, useMutation, useQueryClient,
|
||||
useModules, useModuleSettings
|
||||
},
|
||||
components: {
|
||||
Button, Input, Label, Textarea, Switch, Select,
|
||||
Checkbox, Badge, Card, SettingsLayout, SettingsCard,
|
||||
SchemaForm, SchemaField
|
||||
},
|
||||
icons: {
|
||||
Settings, Save, Trash2, Edit, Plus, X, Check,
|
||||
AlertCircle, Info, Loader2, Chevrons...
|
||||
},
|
||||
utils: {
|
||||
api, toast, __
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Addons don't bundle React (use ours)
|
||||
- Access to all UI components
|
||||
- Consistent styling automatically
|
||||
- Type-safe with TypeScript definitions
|
||||
|
||||
### Dynamic Component Loader
|
||||
|
||||
#### **DynamicComponentLoader.tsx** (NEW)
|
||||
- Loads external React components from addon URLs
|
||||
- Script injection with error handling
|
||||
- Loading and error states
|
||||
- Global namespace management per module
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<DynamicComponentLoader
|
||||
componentUrl="https://example.com/addon.js"
|
||||
moduleId="my-addon"
|
||||
/>
|
||||
```
|
||||
|
||||
### TypeScript Definitions
|
||||
|
||||
#### **types/woonoow-addon.d.ts** (NEW)
|
||||
- Complete type definitions for `window.WooNooW`
|
||||
- Field schema types
|
||||
- Module registration types
|
||||
- Settings schema types
|
||||
- Enables IntelliSense for addon developers
|
||||
|
||||
### Integration
|
||||
|
||||
- Window API initialized in `App.tsx` on mount
|
||||
- `ModuleSettings.tsx` uses `DynamicComponentLoader` for custom components
|
||||
- Seamless fallback to schema-based forms
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Production Polish ✅
|
||||
|
||||
### Biteship Example Addon
|
||||
|
||||
Complete working example demonstrating both approaches:
|
||||
|
||||
#### **examples/biteship-addon/** (NEW)
|
||||
|
||||
**Files**:
|
||||
- `biteship-addon.php` - Main plugin file
|
||||
- `src/Settings.jsx` - Custom React component
|
||||
- `package.json` - Build configuration
|
||||
- `README.md` - Complete documentation
|
||||
|
||||
**Features Demonstrated**:
|
||||
1. Module registration with metadata
|
||||
2. Schema-based settings (Option A)
|
||||
3. Custom React component (Option B)
|
||||
4. Settings persistence
|
||||
5. Module enable/disable integration
|
||||
6. Shipping rate calculation hook
|
||||
7. Settings change reactions
|
||||
8. Test connection button
|
||||
9. Real-world UI patterns
|
||||
|
||||
**Both Approaches Shown**:
|
||||
- **Schema**: 8 fields, no React needed, auto-generated form
|
||||
- **Custom**: Full React component using `window.WooNooW` API
|
||||
|
||||
### Documentation
|
||||
|
||||
Comprehensive README includes:
|
||||
- Installation instructions
|
||||
- File structure
|
||||
- API usage examples
|
||||
- Build configuration
|
||||
- Settings schema reference
|
||||
- Module registration reference
|
||||
- Testing guide
|
||||
- Next steps for real implementation
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Footer Newsletter Form
|
||||
**Problem**: Form not showing despite module enabled
|
||||
**Cause**: Redundant module checks (component + layout)
|
||||
**Solution**: Removed check from `NewsletterForm.tsx`, kept layout-level filtering
|
||||
|
||||
**Files Modified**:
|
||||
- `customer-spa/src/layouts/BaseLayout.tsx` - Added section filtering
|
||||
- `customer-spa/src/components/NewsletterForm.tsx` - Removed redundant check
|
||||
|
||||
---
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files (15)
|
||||
|
||||
**Backend**:
|
||||
1. `includes/Api/ModuleSettingsController.php` - Settings API
|
||||
2. `includes/Modules/NewsletterSettings.php` - Example schema
|
||||
|
||||
**Frontend**:
|
||||
3. `admin-spa/src/components/forms/SchemaField.tsx` - Field renderer
|
||||
4. `admin-spa/src/components/forms/SchemaForm.tsx` - Form renderer
|
||||
5. `admin-spa/src/routes/Settings/ModuleSettings.tsx` - Settings page
|
||||
6. `admin-spa/src/hooks/useModuleSettings.ts` - Settings hook
|
||||
7. `admin-spa/src/lib/windowAPI.ts` - Window API exposure
|
||||
8. `admin-spa/src/components/DynamicComponentLoader.tsx` - Component loader
|
||||
|
||||
**Types**:
|
||||
9. `types/woonoow-addon.d.ts` - TypeScript definitions
|
||||
|
||||
**Example Addon**:
|
||||
10. `examples/biteship-addon/biteship-addon.php` - Main file
|
||||
11. `examples/biteship-addon/src/Settings.jsx` - React component
|
||||
12. `examples/biteship-addon/package.json` - Build config
|
||||
13. `examples/biteship-addon/README.md` - Documentation
|
||||
|
||||
**Documentation**:
|
||||
14. `PHASE_2_3_4_SUMMARY.md` - This file
|
||||
|
||||
### Modified Files (6)
|
||||
|
||||
1. `admin-spa/src/App.tsx` - Added Window API initialization, ModuleSettings route
|
||||
2. `includes/Api/Routes.php` - Registered ModuleSettingsController
|
||||
3. `includes/Core/ModuleRegistry.php` - Added `has_settings: true` to newsletter
|
||||
4. `woonoow.php` - Initialize NewsletterSettings
|
||||
5. `customer-spa/src/layouts/BaseLayout.tsx` - Newsletter section filtering
|
||||
6. `customer-spa/src/components/NewsletterForm.tsx` - Removed redundant check
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Added
|
||||
|
||||
```
|
||||
GET /woonoow/v1/modules/{module_id}/settings
|
||||
POST /woonoow/v1/modules/{module_id}/settings
|
||||
GET /woonoow/v1/modules/{module_id}/schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For Addon Developers
|
||||
|
||||
### Quick Start (Schema-Based)
|
||||
|
||||
```php
|
||||
// 1. Register addon
|
||||
add_filter('woonoow/addon_registry', function($addons) {
|
||||
$addons['my-addon'] = [
|
||||
'name' => 'My Addon',
|
||||
'category' => 'shipping',
|
||||
'has_settings' => true,
|
||||
];
|
||||
return $addons;
|
||||
});
|
||||
|
||||
// 2. Register schema
|
||||
add_filter('woonoow/module_settings_schema', function($schemas) {
|
||||
$schemas['my-addon'] = [
|
||||
'api_key' => [
|
||||
'type' => 'text',
|
||||
'label' => 'API Key',
|
||||
'required' => true,
|
||||
],
|
||||
];
|
||||
return $schemas;
|
||||
});
|
||||
|
||||
// 3. Use settings
|
||||
$settings = get_option('woonoow_module_my-addon_settings');
|
||||
```
|
||||
|
||||
**Result**: Automatic settings page with form, validation, and persistence!
|
||||
|
||||
### Quick Start (Custom React)
|
||||
|
||||
```javascript
|
||||
// Use window.WooNooW API
|
||||
const { React, hooks, components } = window.WooNooW;
|
||||
const { useModuleSettings } = hooks;
|
||||
const { SettingsLayout, Button, Input } = components;
|
||||
|
||||
function MySettings() {
|
||||
const { settings, updateSettings } = useModuleSettings('my-addon');
|
||||
|
||||
return React.createElement(SettingsLayout, { title: 'My Settings' },
|
||||
React.createElement(Input, {
|
||||
value: settings?.api_key || '',
|
||||
onChange: (e) => updateSettings.mutate({ api_key: e.target.value })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Export to global
|
||||
window.WooNooWAddon_my_addon = MySettings;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Phase 2 ✅
|
||||
- [x] Newsletter module shows gear icon
|
||||
- [x] Settings page loads at `/settings/modules/newsletter`
|
||||
- [x] Form renders with 8 fields
|
||||
- [x] Settings save correctly
|
||||
- [x] Settings persist on refresh
|
||||
- [x] Validation works (required fields)
|
||||
- [x] Select dropdown shows WordPress pages
|
||||
|
||||
### Phase 3 ✅
|
||||
- [x] `window.WooNooW` API available in console
|
||||
- [x] All components accessible
|
||||
- [x] All hooks accessible
|
||||
- [x] Dynamic component loader works
|
||||
|
||||
### Phase 4 ✅
|
||||
- [x] Biteship addon structure complete
|
||||
- [x] Both schema and custom approaches documented
|
||||
- [x] Example component uses Window API
|
||||
- [x] Build configuration provided
|
||||
|
||||
### Bug Fixes ✅
|
||||
- [x] Footer newsletter form shows when module enabled
|
||||
- [x] Footer newsletter section hides when module disabled
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Window API**: Initialized once on app mount (~5ms)
|
||||
- **Dynamic Loader**: Lazy loads components only when needed
|
||||
- **Schema Forms**: No runtime overhead, pure React
|
||||
- **Settings API**: Cached by React Query
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
✅ **100% Backward Compatible**
|
||||
- Existing modules work without changes
|
||||
- Schema registration is optional
|
||||
- Custom components are optional
|
||||
- Addons without settings still function
|
||||
- No breaking changes to existing APIs
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
### For Core
|
||||
- [ ] Add conditional field visibility to schema
|
||||
- [ ] Add field dependencies (show field B if field A is true)
|
||||
- [ ] Add file upload field type
|
||||
- [ ] Add color picker field type
|
||||
- [ ] Add repeater field type
|
||||
|
||||
### For Addons
|
||||
- [ ] Create more example addons
|
||||
- [ ] Create addon starter template repository
|
||||
- [ ] Create video tutorials
|
||||
- [ ] Create addon marketplace
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Phase 2, 3, and 4 are complete!** The system now provides:
|
||||
|
||||
1. **Schema-based forms** - No-code settings for simple addons
|
||||
2. **Custom React components** - Full control for complex addons
|
||||
3. **Window API** - Complete toolkit for addon developers
|
||||
4. **Working example** - Biteship addon demonstrates everything
|
||||
5. **TypeScript support** - Type-safe development
|
||||
6. **Documentation** - Comprehensive guides and examples
|
||||
|
||||
**The module system is now production-ready for both built-in modules and external addons!**
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
||||
import { Login } from './routes/Login';
|
||||
import ResetPassword from './routes/ResetPassword';
|
||||
import Dashboard from '@/routes/Dashboard';
|
||||
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
||||
import DashboardOrders from '@/routes/Dashboard/Orders';
|
||||
@@ -44,6 +45,7 @@ import { useActiveSection } from '@/hooks/useActiveSection';
|
||||
import { NAV_TREE_VERSION } from '@/nav/tree';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||
|
||||
function useFullscreen() {
|
||||
const [on, setOn] = useState<boolean>(() => {
|
||||
@@ -98,15 +100,23 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
||||
to={to}
|
||||
end={end}
|
||||
className={(nav) => {
|
||||
// Special case: Dashboard should also match root path "/"
|
||||
const isDashboard = starts === '/dashboard' && location.pathname === '/';
|
||||
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
||||
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
||||
|
||||
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
||||
const matchesChild = childPaths && Array.isArray(childPaths)
|
||||
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
||||
: false;
|
||||
|
||||
const activeByPath = starts ? (location.pathname.startsWith(starts) || isDashboard || matchesChild) : false;
|
||||
// For dashboard: only active if isDashboard is true
|
||||
// For others: active if path starts with their path OR matches a child path
|
||||
let activeByPath = false;
|
||||
if (starts === '/dashboard') {
|
||||
activeByPath = isDashboard;
|
||||
} else if (starts) {
|
||||
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
||||
}
|
||||
|
||||
const mergedActive = nav.isActive || activeByPath;
|
||||
if (typeof className === 'function') {
|
||||
// Preserve caller pattern: className receives { isActive }
|
||||
@@ -123,6 +133,7 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
||||
function Sidebar() {
|
||||
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0";
|
||||
const active = "bg-secondary";
|
||||
const { main } = useActiveSection();
|
||||
|
||||
// Icon mapping
|
||||
const iconMap: Record<string, any> = {
|
||||
@@ -144,19 +155,16 @@ function Sidebar() {
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navTree.map((item: any) => {
|
||||
const IconComponent = iconMap[item.icon] || Package;
|
||||
// Extract child paths for matching
|
||||
const childPaths = item.children?.map((child: any) => child.path).filter(Boolean) || [];
|
||||
const isActive = main.key === item.key;
|
||||
return (
|
||||
<ActiveNavLink
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
startsWith={item.path}
|
||||
childPaths={childPaths}
|
||||
className={({ isActive }: any) => `${link} ${isActive ? active : ''}`}
|
||||
className={`${link} ${isActive ? active : ''}`}
|
||||
>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</ActiveNavLink>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -168,6 +176,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
const link = "inline-flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0";
|
||||
const active = "bg-secondary";
|
||||
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
||||
const { main } = useActiveSection();
|
||||
|
||||
// Icon mapping (same as Sidebar)
|
||||
const iconMap: Record<string, any> = {
|
||||
@@ -189,19 +198,16 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
||||
{navTree.map((item: any) => {
|
||||
const IconComponent = iconMap[item.icon] || Package;
|
||||
// Extract child paths for matching
|
||||
const childPaths = item.children?.map((child: any) => child.path).filter(Boolean) || [];
|
||||
const isActive = main.key === item.key;
|
||||
return (
|
||||
<ActiveNavLink
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
startsWith={item.path}
|
||||
childPaths={childPaths}
|
||||
className={({ isActive }: any) => `${link} ${isActive ? active : ''}`}
|
||||
className={`${link} ${isActive ? active : ''}`}
|
||||
>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</ActiveNavLink>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -239,6 +245,7 @@ import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomizati
|
||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||
import SettingsModules from '@/routes/Settings/Modules';
|
||||
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
||||
import AppearanceIndex from '@/routes/Appearance';
|
||||
import AppearanceGeneral from '@/routes/Appearance/General';
|
||||
import AppearanceHeader from '@/routes/Appearance/Header';
|
||||
@@ -251,6 +258,8 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
@@ -493,6 +502,7 @@ function AppRoutes() {
|
||||
<Routes>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
||||
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
||||
@@ -553,6 +563,7 @@ function AppRoutes() {
|
||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
||||
|
||||
{/* Appearance */}
|
||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||
@@ -569,6 +580,8 @@ function AppRoutes() {
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{addonRoutes.map((route: any) => (
|
||||
@@ -729,6 +742,11 @@ function AuthWrapper() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Initialize Window API for addon developers
|
||||
React.useEffect(() => {
|
||||
initializeWindowAPI();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<HashRouter>
|
||||
|
||||
131
admin-spa/src/components/DynamicComponentLoader.tsx
Normal file
131
admin-spa/src/components/DynamicComponentLoader.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface DynamicComponentLoaderProps {
|
||||
componentUrl: string;
|
||||
moduleId: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Component Loader
|
||||
*
|
||||
* Loads external React components from addons dynamically
|
||||
* The component is loaded as a script and should export a default component
|
||||
*/
|
||||
export function DynamicComponentLoader({
|
||||
componentUrl,
|
||||
moduleId,
|
||||
fallback
|
||||
}: DynamicComponentLoaderProps) {
|
||||
const [Component, setComponent] = useState<React.ComponentType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadComponent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Create a unique global variable name for this component
|
||||
const globalName = `WooNooWAddon_${moduleId.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
// Check if already loaded
|
||||
if ((window as any)[globalName]) {
|
||||
if (mounted) {
|
||||
setComponent(() => (window as any)[globalName]);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script
|
||||
const script = document.createElement('script');
|
||||
script.src = componentUrl;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
// The addon script should assign its component to window[globalName]
|
||||
const loadedComponent = (window as any)[globalName];
|
||||
|
||||
if (!loadedComponent) {
|
||||
if (mounted) {
|
||||
setError(`Component not found. The addon must export to window.${globalName}`);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setComponent(() => loadedComponent);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
if (mounted) {
|
||||
setError('Failed to load component script');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadComponent();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [componentUrl, moduleId]);
|
||||
|
||||
if (loading) {
|
||||
return fallback || (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading component...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to Load Component</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{error}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Component URL: <code className="bg-muted px-2 py-1 rounded">{componentUrl}</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Component not available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component />;
|
||||
}
|
||||
@@ -101,11 +101,13 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
};
|
||||
|
||||
const openEditDialog = (block: EmailBlock) => {
|
||||
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
|
||||
setEditingBlockId(block.id);
|
||||
|
||||
if (block.type === 'card') {
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
||||
setEditingContent(htmlContent);
|
||||
setEditingCardType(block.cardType);
|
||||
} else if (block.type === 'button') {
|
||||
@@ -122,6 +124,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
setEditingAlign(block.align);
|
||||
}
|
||||
|
||||
console.log('[EmailBuilder] Setting editDialogOpen to true');
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -270,28 +273,22 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-2xl"
|
||||
className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
onInteractOutside={(e) => {
|
||||
// Check if WordPress media modal is currently open
|
||||
// Only prevent closing if WordPress media modal is open
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
|
||||
if (wpMediaOpen) {
|
||||
// If WP media is open, ALWAYS prevent dialog from closing
|
||||
// regardless of where the click happened
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// If WP media is not open, prevent closing dialog for outside clicks
|
||||
e.preventDefault();
|
||||
// Otherwise, allow the dialog to close normally via outside click
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Allow escape to close WP media modal
|
||||
// Only prevent escape if WP media modal is open
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
if (wpMediaOpen) {
|
||||
return;
|
||||
e.preventDefault();
|
||||
}
|
||||
e.preventDefault();
|
||||
// Otherwise, allow escape to close dialog
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
@@ -305,7 +302,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 px-6 py-4">
|
||||
{editingBlock?.type === 'card' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -359,7 +356,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
/>
|
||||
{variables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{variables.filter(v => v.includes('_url')).map((variable) => (
|
||||
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||
|
||||
@@ -320,7 +320,24 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check for [card] blocks - match with proper boundaries
|
||||
// Check for [card] blocks - NEW syntax [card:type]...[/card]
|
||||
const newCardMatch = remaining.match(/^\[card:(\w+)\]([\s\S]*?)\[\/card\]/);
|
||||
if (newCardMatch) {
|
||||
const cardType = newCardMatch[1] as CardType;
|
||||
const content = newCardMatch[2].trim();
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'card',
|
||||
cardType,
|
||||
content,
|
||||
});
|
||||
|
||||
remaining = remaining.substring(newCardMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [card] blocks - OLD syntax [card type="..."]...[/card]
|
||||
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1].trim();
|
||||
@@ -347,7 +364,24 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [button] blocks
|
||||
// Check for [button] blocks - NEW syntax [button:style](url)Text[/button]
|
||||
const newButtonMatch = remaining.match(/^\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||
if (newButtonMatch) {
|
||||
blocks.push({
|
||||
id,
|
||||
type: 'button',
|
||||
text: newButtonMatch[3].trim(),
|
||||
link: newButtonMatch[2],
|
||||
style: newButtonMatch[1] as ButtonStyle,
|
||||
align: 'center',
|
||||
widthMode: 'fit',
|
||||
});
|
||||
|
||||
remaining = remaining.substring(newButtonMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for [button] blocks - OLD syntax [button url="..." style="..."]Text[/button]
|
||||
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||
if (buttonMatch) {
|
||||
blocks.push({
|
||||
|
||||
148
admin-spa/src/components/forms/SchemaField.tsx
Normal file
148
admin-spa/src/components/forms/SchemaField.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
export interface FieldSchema {
|
||||
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select';
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: Record<string, string>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SchemaFieldProps {
|
||||
name: string;
|
||||
schema: FieldSchema;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function SchemaField({ name, schema, value, onChange, error }: SchemaFieldProps) {
|
||||
const renderField = () => {
|
||||
switch (schema.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'url':
|
||||
return (
|
||||
<Input
|
||||
type={schema.type}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'toggle':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={schema.disabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">
|
||||
{schema.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select value={value || ''} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={schema.placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schema.options && Object.entries(schema.options).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{schema.type !== 'checkbox' && (
|
||||
<Label htmlFor={name}>
|
||||
{schema.label}
|
||||
{schema.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{renderField()}
|
||||
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schema.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
admin-spa/src/components/forms/SchemaForm.tsx
Normal file
64
admin-spa/src/components/forms/SchemaForm.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SchemaField, FieldSchema } from './SchemaField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export type FormSchema = Record<string, FieldSchema>;
|
||||
|
||||
interface SchemaFormProps {
|
||||
schema: FormSchema;
|
||||
initialValues?: Record<string, any>;
|
||||
onSubmit: (values: Record<string, any>) => void | Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
submitLabel?: string;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SchemaForm({
|
||||
schema,
|
||||
initialValues = {},
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'Save Settings',
|
||||
errors = {},
|
||||
}: SchemaFormProps) {
|
||||
const [values, setValues] = useState<Record<string, any>>(initialValues);
|
||||
|
||||
useEffect(() => {
|
||||
setValues(initialValues);
|
||||
}, [initialValues]);
|
||||
|
||||
const handleChange = (name: string, value: any) => {
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{Object.entries(schema).map(([name, fieldSchema]) => (
|
||||
<SchemaField
|
||||
key={name}
|
||||
name={name}
|
||||
schema={fieldSchema}
|
||||
value={values[name]}
|
||||
onChange={(value) => handleChange(name, value)}
|
||||
error={errors[name]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,10 @@ export default function SubmenuBar({ items = [], fullscreen = false, headerVisib
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{items.map((it) => {
|
||||
const key = `${it.label}-${it.path || it.href}`;
|
||||
// Check if current path starts with the submenu path (for sub-pages like /settings/notifications/staff)
|
||||
const isActive = !!it.path && (pathname === it.path || pathname.startsWith(it.path + '/'));
|
||||
// Determine active state based on exact pathname match
|
||||
// Only ONE submenu item should be active at a time
|
||||
const isActive = it.path === pathname;
|
||||
|
||||
const cls = [
|
||||
'ui-ctrl inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap',
|
||||
'focus:outline-none focus:ring-0 focus:shadow-none',
|
||||
|
||||
@@ -30,25 +30,43 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
// Get or create portal container inside the app for proper CSS scoping
|
||||
const getPortalContainer = () => {
|
||||
const appContainer = document.getElementById('woonoow-admin-app');
|
||||
if (!appContainer) return document.body;
|
||||
|
||||
let portalRoot = document.getElementById('woonoow-dialog-portal');
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-dialog-portal';
|
||||
appContainer.appendChild(portalRoot);
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogPortal container={getPortalContainer()}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[99999] flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
})
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
@@ -57,7 +75,7 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left px-6 pt-6 pb-4 border-b",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +89,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t mt-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -106,6 +124,20 @@ const DialogDescription = React.forwardRef<
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
const DialogBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto px-6 py-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogBody.displayName = "DialogBody"
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
@@ -117,4 +149,5 @@ export {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogBody,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Label } from './label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogBody } from './dialog';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
@@ -45,7 +45,8 @@ export function RichTextEditor({
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
// StarterKit 3.10+ includes Link by default, disable since we configure separately
|
||||
StarterKit.configure({ link: false }),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
@@ -75,14 +76,6 @@ export function RichTextEditor({
|
||||
class:
|
||||
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
|
||||
},
|
||||
handleClick: (view, pos, event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'A' || target.closest('a')) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,6 +113,8 @@ export function RichTextEditor({
|
||||
const [buttonText, setButtonText] = useState('Click Here');
|
||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
|
||||
const [isEditingButton, setIsEditingButton] = useState(false);
|
||||
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||
|
||||
const addImage = () => {
|
||||
openWPMediaImage((file) => {
|
||||
@@ -135,12 +130,81 @@ export function RichTextEditor({
|
||||
setButtonText('Click Here');
|
||||
setButtonHref('{order_url}');
|
||||
setButtonStyle('solid');
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
setButtonDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle clicking on buttons in the editor to edit them
|
||||
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
|
||||
|
||||
if (buttonEl && editor) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get button attributes
|
||||
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
|
||||
const href = buttonEl.getAttribute('data-href') || '#';
|
||||
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
|
||||
|
||||
// Find the position of this button node
|
||||
const { state } = editor.view;
|
||||
let foundPos: number | null = null;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'button' &&
|
||||
node.attrs.text === text &&
|
||||
node.attrs.href === href) {
|
||||
foundPos = pos;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Open dialog in edit mode
|
||||
setButtonText(text);
|
||||
setButtonHref(href);
|
||||
setButtonStyle(style);
|
||||
setIsEditingButton(true);
|
||||
setEditingButtonPos(foundPos);
|
||||
setButtonDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const insertButton = () => {
|
||||
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
||||
if (isEditingButton && editingButtonPos !== null && editor) {
|
||||
// Delete old button and insert new one at same position
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||
.insertContentAt(editingButtonPos, {
|
||||
type: 'button',
|
||||
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
// Insert new button
|
||||
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
||||
}
|
||||
setButtonDialogOpen(false);
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
};
|
||||
|
||||
const deleteButton = () => {
|
||||
if (editingButtonPos !== null && editor) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||
.run();
|
||||
setButtonDialogOpen(false);
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveHeading = () => {
|
||||
@@ -292,97 +356,174 @@ export function RichTextEditor({
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="overflow-y-auto max-h-[400px] min-h-[200px]">
|
||||
<div onClick={handleEditorClick}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{/* Variables Dropdown */}
|
||||
{/* Variables - Collapsible and Categorized */}
|
||||
{variables.length > 0 && (
|
||||
<div className="border-t bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="variable-select" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{__('Insert Variable:')}
|
||||
</Label>
|
||||
<Select onValueChange={(value) => insertVariable(value)}>
|
||||
<SelectTrigger id="variable-select" className="h-8 text-xs">
|
||||
<SelectValue placeholder={__('Choose a variable...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{variables.map((variable) => (
|
||||
<SelectItem key={variable} value={variable} className="text-xs">
|
||||
{`{${variable}}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<details className="border-t bg-muted/30">
|
||||
<summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
|
||||
<span className="text-[10px]">▶</span>
|
||||
{__('Insert Variable')}
|
||||
<span className="text-[10px] opacity-60">({variables.length})</span>
|
||||
</summary>
|
||||
<div className="p-3 pt-0 space-y-3">
|
||||
{/* Order Variables */}
|
||||
{variables.some(v => v.startsWith('order')) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('order')).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
onClick={() => insertVariable(variable)}
|
||||
className="text-[11px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Customer Variables */}
|
||||
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Customer')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('customer') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
onClick={() => insertVariable(variable)}
|
||||
className="text-[11px] px-1.5 py-0.5 bg-green-50 text-green-700 rounded hover:bg-green-100 transition-colors"
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Shipping/Payment Variables */}
|
||||
{variables.some(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Shipping & Payment')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
onClick={() => insertVariable(variable)}
|
||||
className="text-[11px] px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Store/Site Variables */}
|
||||
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
onClick={() => insertVariable(variable)}
|
||||
className="text-[11px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Button Dialog */}
|
||||
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
|
||||
setButtonDialogOpen(open);
|
||||
if (!open) {
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Insert Button')}</DialogTitle>
|
||||
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Add a styled button to your content. Use variables for dynamic links.')}
|
||||
{isEditingButton
|
||||
? __('Edit the button properties below. Click on the button to save.')
|
||||
: __('Add a styled button to your content. Use variables for dynamic links.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||
<Input
|
||||
id="btn-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder={__('e.g., View Order')}
|
||||
/>
|
||||
</div>
|
||||
<DialogBody>
|
||||
<div className="space-y-4 !p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||
<Input
|
||||
id="btn-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder={__('e.g., View Order')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
||||
<Input
|
||||
id="btn-href"
|
||||
value={buttonHref}
|
||||
onChange={(e) => setButtonHref(e.target.value)}
|
||||
placeholder="{order_url}"
|
||||
/>
|
||||
{variables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{variables.filter(v => v.includes('_url')).map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
||||
<Input
|
||||
id="btn-href"
|
||||
value={buttonHref}
|
||||
onChange={(e) => setButtonHref(e.target.value)}
|
||||
placeholder="{order_url}"
|
||||
/>
|
||||
{variables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
{isEditingButton && (
|
||||
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
|
||||
{__('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={insertButton}>
|
||||
{__('Insert Button')}
|
||||
{isEditingButton ? __('Update Button') : __('Insert Button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -69,33 +69,49 @@ SelectScrollDownButton.displayName =
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
>(({ className, children, position = "popper", ...props }, ref) => {
|
||||
// Get or create portal container inside the app for proper CSS scoping
|
||||
const getPortalContainer = () => {
|
||||
const appContainer = document.getElementById('woonoow-admin-app');
|
||||
if (!appContainer) return document.body;
|
||||
|
||||
let portalRoot = document.getElementById('woonoow-select-portal');
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-select-portal';
|
||||
appContainer.appendChild(portalRoot);
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal container={getPortalContainer()}>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-1",
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
})
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
|
||||
@@ -37,54 +37,50 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a[data-button]',
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||
style: node.getAttribute('data-style') || 'solid',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a.button',
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
style: 'solid',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a.button-outline',
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
style: 'outline',
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { text, href, style } = HTMLAttributes;
|
||||
const className = style === 'outline' ? 'button-outline' : 'button';
|
||||
|
||||
const buttonStyle: Record<string, string> = style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
background: '#7f54b3',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
: {
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: '#7f54b3',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid #7f54b3',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
// Simple link styling - no fancy button appearance in editor
|
||||
// The actual button styling happens in email rendering (EmailRenderer.php)
|
||||
// In editor, just show as a styled link (differentiable from regular links)
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, {
|
||||
href,
|
||||
class: className,
|
||||
style: Object.entries(buttonStyle)
|
||||
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
|
||||
.join('; '),
|
||||
class: 'button-node',
|
||||
style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;',
|
||||
'data-button': '',
|
||||
'data-text': text,
|
||||
'data-href': href,
|
||||
'data-style': style,
|
||||
title: `Button: ${text} → ${href}`,
|
||||
}),
|
||||
text,
|
||||
];
|
||||
@@ -94,12 +90,12 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
return {
|
||||
setButton:
|
||||
(options) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,12 @@ export function useActiveSection(): { main: MainNode; all: MainNode[] } {
|
||||
if (settingsNode) return settingsNode;
|
||||
}
|
||||
|
||||
// Special case: /coupons should match marketing section
|
||||
if (pathname === '/coupons' || pathname.startsWith('/coupons/')) {
|
||||
const marketingNode = navTree.find(n => n.key === 'marketing');
|
||||
if (marketingNode) return marketingNode;
|
||||
}
|
||||
|
||||
// Try to find section by matching path prefix
|
||||
for (const node of navTree) {
|
||||
if (node.path === '/') continue; // Skip dashboard for now
|
||||
|
||||
45
admin-spa/src/hooks/useModuleSettings.ts
Normal file
45
admin-spa/src/hooks/useModuleSettings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Hook to manage module-specific settings
|
||||
*
|
||||
* @param moduleId - The module ID
|
||||
* @returns Settings data and mutation functions
|
||||
*/
|
||||
export function useModuleSettings(moduleId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['module-settings', moduleId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/modules/${moduleId}/settings`);
|
||||
return response as Record<string, any>;
|
||||
},
|
||||
enabled: !!moduleId,
|
||||
});
|
||||
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: async (newSettings: Record<string, any>) => {
|
||||
return api.post(`/modules/${moduleId}/settings`, newSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['module-settings', moduleId] });
|
||||
toast.success('Settings saved successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.message || 'Failed to save settings';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
settings: settings || {},
|
||||
isLoading,
|
||||
updateSettings,
|
||||
saveSetting: (key: string, value: any) => {
|
||||
updateSettings.mutate({ ...settings, [key]: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function useModules() {
|
||||
queryKey: ['modules-enabled'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules/enabled');
|
||||
return response.data;
|
||||
return response || { enabled: [] };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
@@ -76,6 +76,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WordPress Admin Override Fixes
|
||||
These rules use high specificity + !important
|
||||
to override WordPress admin CSS conflicts
|
||||
============================================ */
|
||||
|
||||
/* Fix SVG icon styling - WordPress sets fill:currentColor on all SVGs */
|
||||
#woonoow-admin-app svg {
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
/* But allow explicit fill-current class to work for filled icons */
|
||||
#woonoow-admin-app svg.fill-current,
|
||||
#woonoow-admin-app .fill-current svg,
|
||||
#woonoow-admin-app [class*="fill-"] svg {
|
||||
fill: currentColor !important;
|
||||
}
|
||||
|
||||
/* Fix radio button indicator - WordPress overrides circle fill */
|
||||
#woonoow-admin-app [data-radix-radio-group-item] svg,
|
||||
#woonoow-admin-app [role="radio"] svg {
|
||||
fill: currentColor !important;
|
||||
}
|
||||
|
||||
/* Fix font-weight inheritance - prevent WordPress bold overrides */
|
||||
#woonoow-admin-app text,
|
||||
#woonoow-admin-app tspan {
|
||||
font-weight: inherit !important;
|
||||
}
|
||||
|
||||
/* Reset form element styling that WordPress overrides */
|
||||
#woonoow-admin-app input[type="radio"],
|
||||
#woonoow-admin-app input[type="checkbox"] {
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
/* Command palette input: remove native borders/shadows to match shadcn */
|
||||
.command-palette-search {
|
||||
border: none !important;
|
||||
|
||||
@@ -22,7 +22,33 @@ export function htmlToMarkdown(html: string): string {
|
||||
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
||||
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Links
|
||||
// TipTap buttons - detect by data-button attribute, BEFORE generic links
|
||||
// Format: <a data-button data-style="solid" data-href="..." data-text="...">text</a>
|
||||
// or: <a href="..." class="button..." data-button ...>text</a>
|
||||
markdown = markdown.replace(/<a[^>]*data-button[^>]*>(.*?)<\/a>/gi, (match, text) => {
|
||||
// Extract style from data-style or class
|
||||
let style = 'solid';
|
||||
const styleMatch = match.match(/data-style=["'](\w+)["']/);
|
||||
if (styleMatch) {
|
||||
style = styleMatch[1];
|
||||
} else if (match.includes('button-outline') || match.includes('outline')) {
|
||||
style = 'outline';
|
||||
}
|
||||
|
||||
// Extract href from data-href or href attribute
|
||||
let url = '#';
|
||||
const dataHrefMatch = match.match(/data-href=["']([^"']+)["']/);
|
||||
const hrefMatch = match.match(/href=["']([^"']+)["']/);
|
||||
if (dataHrefMatch) {
|
||||
url = dataHrefMatch[1];
|
||||
} else if (hrefMatch) {
|
||||
url = hrefMatch[1];
|
||||
}
|
||||
|
||||
return `[button:${style}](${url})${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Regular links (not buttons)
|
||||
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||
|
||||
// Lists
|
||||
|
||||
@@ -98,13 +98,13 @@ export function markdownToHtml(markdown: string): string {
|
||||
// Parse [button:style](url)Text[/button] (new syntax)
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
});
|
||||
|
||||
// Parse remaining markdown
|
||||
@@ -151,15 +151,20 @@ export function parseMarkdownBasics(text: string): string {
|
||||
|
||||
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
||||
// Allow whitespace and newlines between parts
|
||||
// Include data-button attributes for TipTap recognition
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
const trimmedText = text.trim();
|
||||
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
|
||||
});
|
||||
|
||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||
// Include data-button attributes for TipTap recognition
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
const buttonStyle = style || 'solid';
|
||||
const buttonClass = buttonStyle === 'outline' ? 'button-outline' : 'button';
|
||||
const trimmedText = text.trim();
|
||||
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${buttonStyle}">${trimmedText}</a>`;
|
||||
});
|
||||
|
||||
// Images (must come before links)
|
||||
@@ -267,8 +272,33 @@ export function htmlToMarkdown(html: string): string {
|
||||
});
|
||||
|
||||
// Convert buttons back to [button] syntax
|
||||
// TipTap button format with data attributes: <a data-button data-href="..." data-style="..." data-text="...">text</a>
|
||||
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-href="([^"]+)"[^>]*data-style="([^"]*)"[^>]*>([^<]+)<\/a>/gi, (match, url, style, text) => {
|
||||
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
|
||||
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Alternate order: data-style before data-href
|
||||
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-style="([^"]*)"[^>]*data-href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, (match, style, url, text) => {
|
||||
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
|
||||
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Simple data-button fallback (just has href and class)
|
||||
markdown = markdown.replace(/<a[^>]*href="([^"]+)"[^>]*class="(button[^"]*)"[^>]*data-button[^>]*>([^<]+)<\/a>/gi, (match, url, className, text) => {
|
||||
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Buttons wrapped in p tags (from preview HTML): <p><a href="..." class="button...">text</a></p>
|
||||
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
||||
const style = className.includes('outline') ? ' style="outline"' : '';
|
||||
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
// Direct button links without p wrapper
|
||||
markdown = markdown.replace(/<a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a>/g, (match, url, className, text) => {
|
||||
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||
});
|
||||
|
||||
|
||||
200
admin-spa/src/lib/windowAPI.ts
Normal file
200
admin-spa/src/lib/windowAPI.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* WooNooW Window API
|
||||
*
|
||||
* Exposes React, hooks, components, and utilities to addon developers
|
||||
* via window.WooNooW object
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// UI Components
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
// Settings Components
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
|
||||
// Form Components
|
||||
import { SchemaForm } from '@/components/forms/SchemaForm';
|
||||
import { SchemaField } from '@/components/forms/SchemaField';
|
||||
|
||||
// Hooks
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
|
||||
// Utils
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
// Icons (commonly used)
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* WooNooW Window API Interface
|
||||
*/
|
||||
export interface WooNooWAPI {
|
||||
React: typeof React;
|
||||
ReactDOM: typeof ReactDOM;
|
||||
|
||||
hooks: {
|
||||
useQuery: typeof useQuery;
|
||||
useMutation: typeof useMutation;
|
||||
useQueryClient: typeof useQueryClient;
|
||||
useModules: typeof useModules;
|
||||
useModuleSettings: typeof useModuleSettings;
|
||||
};
|
||||
|
||||
components: {
|
||||
// Basic UI
|
||||
Button: typeof Button;
|
||||
Input: typeof Input;
|
||||
Label: typeof Label;
|
||||
Textarea: typeof Textarea;
|
||||
Switch: typeof Switch;
|
||||
Select: typeof Select;
|
||||
SelectContent: typeof SelectContent;
|
||||
SelectItem: typeof SelectItem;
|
||||
SelectTrigger: typeof SelectTrigger;
|
||||
SelectValue: typeof SelectValue;
|
||||
Checkbox: typeof Checkbox;
|
||||
Badge: typeof Badge;
|
||||
Card: typeof Card;
|
||||
CardContent: typeof CardContent;
|
||||
CardDescription: typeof CardDescription;
|
||||
CardFooter: typeof CardFooter;
|
||||
CardHeader: typeof CardHeader;
|
||||
CardTitle: typeof CardTitle;
|
||||
|
||||
// Settings Components
|
||||
SettingsLayout: typeof SettingsLayout;
|
||||
SettingsCard: typeof SettingsCard;
|
||||
SettingsSection: typeof SettingsSection;
|
||||
|
||||
// Form Components
|
||||
SchemaForm: typeof SchemaForm;
|
||||
SchemaField: typeof SchemaField;
|
||||
};
|
||||
|
||||
icons: {
|
||||
Settings: typeof Settings;
|
||||
Save: typeof Save;
|
||||
Trash2: typeof Trash2;
|
||||
Edit: typeof Edit;
|
||||
Plus: typeof Plus;
|
||||
X: typeof X;
|
||||
Check: typeof Check;
|
||||
AlertCircle: typeof AlertCircle;
|
||||
Info: typeof Info;
|
||||
Loader2: typeof Loader2;
|
||||
ChevronDown: typeof ChevronDown;
|
||||
ChevronUp: typeof ChevronUp;
|
||||
ChevronLeft: typeof ChevronLeft;
|
||||
ChevronRight: typeof ChevronRight;
|
||||
};
|
||||
|
||||
utils: {
|
||||
api: typeof api;
|
||||
toast: typeof toast;
|
||||
__: typeof __;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Window API
|
||||
* Exposes WooNooW API to window object for addon developers
|
||||
*/
|
||||
export function initializeWindowAPI() {
|
||||
const windowAPI: WooNooWAPI = {
|
||||
React,
|
||||
ReactDOM,
|
||||
|
||||
hooks: {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useModules,
|
||||
useModuleSettings,
|
||||
},
|
||||
|
||||
components: {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Checkbox,
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
SettingsLayout,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SchemaForm,
|
||||
SchemaField,
|
||||
},
|
||||
|
||||
icons: {
|
||||
Settings,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
},
|
||||
|
||||
utils: {
|
||||
api,
|
||||
toast,
|
||||
__,
|
||||
},
|
||||
};
|
||||
|
||||
// Expose to window
|
||||
(window as any).WooNooW = windowAPI;
|
||||
|
||||
console.log('✅ WooNooW API initialized for addon developers');
|
||||
}
|
||||
@@ -37,7 +37,7 @@ interface ContactData {
|
||||
}
|
||||
|
||||
export default function AppearanceFooter() {
|
||||
const { isEnabled } = useModules();
|
||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [columns, setColumns] = useState('4');
|
||||
const [style, setStyle] = useState('detailed');
|
||||
@@ -170,16 +170,17 @@ export default function AppearanceFooter() {
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/footer', {
|
||||
const payload = {
|
||||
columns,
|
||||
style,
|
||||
copyright_text: copyrightText,
|
||||
copyrightText,
|
||||
elements,
|
||||
social_links: socialLinks,
|
||||
socialLinks,
|
||||
sections,
|
||||
contact_data: contactData,
|
||||
contactData,
|
||||
labels,
|
||||
});
|
||||
};
|
||||
const response = await api.post('/appearance/footer', payload);
|
||||
toast.success('Footer settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
|
||||
@@ -12,9 +12,18 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface WordPressPage {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function AppearanceGeneral() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
||||
const [spaPage, setSpaPage] = useState(0);
|
||||
const [availablePages, setAvailablePages] = useState<WordPressPage[]>([]);
|
||||
const [toastPosition, setToastPosition] = useState('top-right');
|
||||
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
||||
const [predefinedPair, setPredefinedPair] = useState('modern');
|
||||
const [customHeading, setCustomHeading] = useState('');
|
||||
@@ -39,11 +48,14 @@ export default function AppearanceGeneral() {
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load appearance settings
|
||||
const response = await api.get('/appearance/settings');
|
||||
const general = response.data?.general;
|
||||
|
||||
if (general) {
|
||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
||||
if (general.toast_position) setToastPosition(general.toast_position);
|
||||
if (general.typography) {
|
||||
setTypographyMode(general.typography.mode || 'predefined');
|
||||
setPredefinedPair(general.typography.predefined_pair || 'modern');
|
||||
@@ -61,8 +73,19 @@ export default function AppearanceGeneral() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load available pages
|
||||
const pagesResponse = await api.get('/pages/list');
|
||||
console.log('Pages API response:', pagesResponse);
|
||||
if (pagesResponse.data) {
|
||||
console.log('Pages loaded:', pagesResponse.data);
|
||||
setAvailablePages(pagesResponse.data);
|
||||
} else {
|
||||
console.warn('No pages data in response:', pagesResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
console.error('Error details:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,7 +97,9 @@ export default function AppearanceGeneral() {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/general', {
|
||||
spa_mode: spaMode,
|
||||
spaMode,
|
||||
spaPage,
|
||||
toastPosition,
|
||||
typography: {
|
||||
mode: typographyMode,
|
||||
predefined_pair: typographyMode === 'predefined' ? predefinedPair : undefined,
|
||||
@@ -110,7 +135,7 @@ export default function AppearanceGeneral() {
|
||||
Disabled
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use WordPress default pages (no SPA functionality)
|
||||
SPA never loads (use WordPress default pages)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +147,7 @@ export default function AppearanceGeneral() {
|
||||
Checkout Only
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SPA for checkout flow only (cart, checkout, thank you)
|
||||
SPA starts at cart page (cart → checkout → thank you → account)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,13 +159,78 @@ export default function AppearanceGeneral() {
|
||||
Full SPA
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Entire customer-facing site uses SPA (recommended)
|
||||
SPA starts at shop page (shop → product → cart → checkout → account)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SettingsCard>
|
||||
|
||||
{/* SPA Page */}
|
||||
<SettingsCard
|
||||
title="SPA Page"
|
||||
description="Select the page where the SPA will load (e.g., /store)"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This page will render the full SPA to the body element with no theme interference.
|
||||
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
||||
<Select
|
||||
value={spaPage.toString()}
|
||||
onValueChange={(value) => setSpaPage(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id="spa-page">
|
||||
<SelectValue placeholder="Select a page..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">— None —</SelectItem>
|
||||
{availablePages.map((page) => (
|
||||
<SelectItem key={page.id} value={page.id.toString()}>
|
||||
{page.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
<strong>Full SPA:</strong> Loads shop page initially<br />
|
||||
<strong>Checkout Only:</strong> Loads cart page initially<br />
|
||||
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<SettingsCard
|
||||
title="Toast Notifications"
|
||||
description="Configure notification position"
|
||||
>
|
||||
<SettingsSection label="Position" htmlFor="toast-position">
|
||||
<Select value={toastPosition} onValueChange={setToastPosition}>
|
||||
<SelectTrigger id="toast-position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top-left">Top Left</SelectItem>
|
||||
<SelectItem value="top-center">Top Center</SelectItem>
|
||||
<SelectItem value="top-right">Top Right</SelectItem>
|
||||
<SelectItem value="bottom-left">Bottom Left</SelectItem>
|
||||
<SelectItem value="bottom-center">Bottom Center</SelectItem>
|
||||
<SelectItem value="bottom-right">Bottom Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Choose where toast notifications appear on the screen
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Typography */}
|
||||
<SettingsCard
|
||||
title="Typography"
|
||||
|
||||
400
admin-spa/src/routes/Marketing/Campaigns/Edit.tsx
Normal file
400
admin-spa/src/routes/Marketing/Campaigns/Edit.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Send,
|
||||
Eye,
|
||||
TestTube,
|
||||
Save,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Campaign {
|
||||
id: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
status: string;
|
||||
scheduled_at: string | null;
|
||||
}
|
||||
|
||||
export default function CampaignEdit() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isNew = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [showSendConfirm, setShowSendConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Fetch campaign if editing
|
||||
const { data: campaign, isLoading } = useQuery({
|
||||
queryKey: ['campaign', id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/campaigns/${id}`);
|
||||
return response.data as Campaign;
|
||||
},
|
||||
enabled: !isNew && !!id,
|
||||
});
|
||||
|
||||
// Populate form when campaign loads
|
||||
useEffect(() => {
|
||||
if (campaign) {
|
||||
setTitle(campaign.title || '');
|
||||
setSubject(campaign.subject || '');
|
||||
setContent(campaign.content || '');
|
||||
}
|
||||
}, [campaign]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data: { title: string; subject: string; content: string; status?: string }) => {
|
||||
if (isNew) {
|
||||
return api.post('/campaigns', data);
|
||||
} else {
|
||||
return api.put(`/campaigns/${id}`, data);
|
||||
}
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
toast.success(isNew ? __('Campaign created') : __('Campaign saved'));
|
||||
if (isNew && response?.data?.id) {
|
||||
navigate(`/marketing/campaigns/${response.data.id}`, { replace: true });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to save campaign'));
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// First save, then preview
|
||||
let campaignId = id;
|
||||
if (isNew || !id) {
|
||||
const saveResponse = await api.post('/campaigns', { title, subject, content, status: 'draft' });
|
||||
campaignId = saveResponse?.data?.id;
|
||||
if (campaignId) {
|
||||
navigate(`/marketing/campaigns/${campaignId}`, { replace: true });
|
||||
}
|
||||
} else {
|
||||
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||
}
|
||||
|
||||
const response = await api.get(`/campaigns/${campaignId}/preview`);
|
||||
return response;
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
setPreviewHtml(response?.html || response?.data?.html || '');
|
||||
setShowPreview(true);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to generate preview'));
|
||||
},
|
||||
});
|
||||
|
||||
// Test email mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
// First save
|
||||
if (!isNew && id) {
|
||||
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||
}
|
||||
return api.post(`/campaigns/${id}/test`, { email });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Test email sent'));
|
||||
setShowTestDialog(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to send test email'));
|
||||
},
|
||||
});
|
||||
|
||||
// Send mutation
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// First save
|
||||
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||
return api.post(`/campaigns/${id}/send`);
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['campaign', id] });
|
||||
toast.success(response?.message || __('Campaign sent successfully'));
|
||||
setShowSendConfirm(false);
|
||||
navigate('/marketing/campaigns');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.response?.data?.error || __('Failed to send campaign'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error(__('Please enter a title'));
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveMutation.mutateAsync({ title, subject, content, status: 'draft' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = !isNew && id && campaign?.status !== 'sent' && campaign?.status !== 'sending';
|
||||
|
||||
if (!isNew && isLoading) {
|
||||
return (
|
||||
<SettingsLayout title={__('Loading...')} description="">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={isNew ? __('New Campaign') : __('Edit Campaign')}
|
||||
description={isNew ? __('Create a new email campaign') : campaign?.title || ''}
|
||||
>
|
||||
{/* Back button */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/marketing/campaigns')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Campaigns')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Campaign Details */}
|
||||
<SettingsCard
|
||||
title={__('Campaign Details')}
|
||||
description={__('Basic information about your campaign')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Campaign Title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={__('e.g., Holiday Sale Announcement')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Internal name for this campaign (not shown to subscribers)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Email Subject')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
placeholder={__('e.g., 🎄 Exclusive Holiday Deals Inside!')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('The subject line subscribers will see in their inbox')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Campaign Content */}
|
||||
<SettingsCard
|
||||
title={__('Campaign Content')}
|
||||
description={__('Write your newsletter content. The design template is configured in Settings > Notifications.')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">{__('Email Content')}</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder={__('Write your newsletter content here...\n\nYou can use:\n- {site_name} - Your store name\n- {current_date} - Today\'s date\n- {subscriber_email} - Subscriber\'s email')}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[300px] font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Use HTML for rich formatting. The design wrapper will be applied from your campaign email template.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending || !title.trim()}
|
||||
>
|
||||
{previewMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{__('Preview')}
|
||||
</Button>
|
||||
|
||||
{!isNew && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTestDialog(true)}
|
||||
disabled={!id}
|
||||
>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
{__('Send Test')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !title.trim()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{__('Save Draft')}
|
||||
</Button>
|
||||
|
||||
{canSend && (
|
||||
<Button
|
||||
onClick={() => setShowSendConfirm(true)}
|
||||
disabled={sendMutation.isPending}
|
||||
>
|
||||
{sendMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{__('Send Now')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Email Preview')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="border rounded-lg bg-white p-4">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Test Email Dialog */}
|
||||
<Dialog open={showTestDialog} onOpenChange={setShowTestDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||
<Input
|
||||
id="test-email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setShowTestDialog(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => testMutation.mutate(testEmail)}
|
||||
disabled={!testEmail || testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{__('Send Test')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Send Confirmation Dialog */}
|
||||
<AlertDialog open={showSendConfirm} onOpenChange={setShowSendConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Send Campaign')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('Are you sure you want to send this campaign to all newsletter subscribers? This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={sendMutation.isPending}
|
||||
>
|
||||
{sendMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{__('Send to All Subscribers')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
293
admin-spa/src/routes/Marketing/Campaigns/index.tsx
Normal file
293
admin-spa/src/routes/Marketing/Campaigns/index.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Copy
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Campaign {
|
||||
id: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||
recipient_count: number;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
draft: { label: 'Draft', icon: Edit, className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
|
||||
scheduled: { label: 'Scheduled', icon: Clock, className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
sending: { label: 'Sending', icon: Send, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
sent: { label: 'Sent', icon: CheckCircle2, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
failed: { label: 'Failed', icon: AlertCircle, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
};
|
||||
|
||||
export default function CampaignsList() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['campaigns'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/campaigns');
|
||||
return response.data as Campaign[];
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.del(`/campaigns/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
toast.success(__('Campaign deleted'));
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete campaign'));
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: async (campaign: Campaign) => {
|
||||
const response = await api.post('/campaigns', {
|
||||
title: `${campaign.title} (Copy)`,
|
||||
subject: campaign.subject,
|
||||
content: '', // Would need to fetch full content
|
||||
status: 'draft',
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
toast.success(__('Campaign duplicated'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to duplicate campaign'));
|
||||
},
|
||||
});
|
||||
|
||||
const campaigns = data || [];
|
||||
const filteredCampaigns = campaigns.filter((c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.subject?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Campaigns')}
|
||||
description={__('Create and send email campaigns to your newsletter subscribers')}
|
||||
>
|
||||
<SettingsCard
|
||||
title={__('All Campaigns')}
|
||||
description={`${campaigns.length} ${__('campaigns total')}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Search campaigns...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('New Campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading campaigns...')}
|
||||
</div>
|
||||
) : filteredCampaigns.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? __('No campaigns found matching your search') : (
|
||||
<div className="space-y-4">
|
||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||
<p>{__('No campaigns yet')}</p>
|
||||
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('Create your first campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Title')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCampaigns.map((campaign) => {
|
||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{campaign.title}</div>
|
||||
{campaign.subject && (
|
||||
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{campaign.subject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{__(status.label)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{campaign.status === 'sent' ? (
|
||||
<span>
|
||||
{campaign.sent_count}/{campaign.recipient_count}
|
||||
{campaign.failed_count > 0 && (
|
||||
<span className="text-red-500 ml-1">
|
||||
({campaign.failed_count} failed)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{campaign.sent_at
|
||||
? formatDate(campaign.sent_at)
|
||||
: campaign.scheduled_at
|
||||
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
|
||||
: formatDate(campaign.created_at)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/marketing/campaigns/${campaign.id}`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{__('Duplicate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(campaign.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete Campaign')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('Are you sure you want to delete this campaign? This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -24,32 +24,14 @@ export default function NewsletterSubscribers() {
|
||||
const navigate = useNavigate();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
if (!isEnabled('newsletter')) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Newsletter Subscribers"
|
||||
description="Newsletter module is disabled"
|
||||
>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/settings/modules')}>
|
||||
Go to Module Settings
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Always call ALL hooks before any conditional returns
|
||||
const { data: subscribersData, isLoading } = useQuery({
|
||||
queryKey: ['newsletter-subscribers'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/newsletter/subscribers');
|
||||
return response.data;
|
||||
},
|
||||
enabled: isEnabled('newsletter'), // Only fetch when module is enabled
|
||||
});
|
||||
|
||||
const deleteSubscriber = useMutation({
|
||||
@@ -88,6 +70,26 @@ export default function NewsletterSubscribers() {
|
||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (!isEnabled('newsletter')) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Newsletter Subscribers"
|
||||
description="Newsletter module is disabled"
|
||||
>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/settings/modules')}>
|
||||
Go to Module Settings
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Newsletter Subscribers"
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { Mail, Send, Tag } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface MarketingCard {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const cards: MarketingCard[] = [
|
||||
{
|
||||
title: __('Newsletter'),
|
||||
description: __('Manage subscribers and email templates'),
|
||||
icon: Mail,
|
||||
to: '/marketing/newsletter',
|
||||
},
|
||||
{
|
||||
title: __('Campaigns'),
|
||||
description: __('Create and send email campaigns'),
|
||||
icon: Send,
|
||||
to: '/marketing/campaigns',
|
||||
},
|
||||
{
|
||||
title: __('Coupons'),
|
||||
description: __('Discounts, promotions, and coupon codes'),
|
||||
icon: Tag,
|
||||
to: '/marketing/coupons',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Marketing() {
|
||||
return <Navigate to="/marketing/newsletter" replace />;
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Marketing')}
|
||||
description={__('Newsletter, campaigns, and promotions')}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<button
|
||||
key={card.to}
|
||||
onClick={() => navigate(card.to)}
|
||||
className="flex items-start gap-4 p-6 rounded-lg border bg-card hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
<card.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{card.title}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{card.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink } from 'lucide-react';
|
||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { useApp } from '@/contexts/AppContext';
|
||||
@@ -16,10 +16,10 @@ interface MenuItem {
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
icon: <Tag className="w-5 h-5" />,
|
||||
label: __('Coupons'),
|
||||
description: __('Manage discount codes and promotions'),
|
||||
to: '/coupons'
|
||||
icon: <Megaphone className="w-5 h-5" />,
|
||||
label: __('Marketing'),
|
||||
description: __('Newsletter, coupons, and promotions'),
|
||||
to: '/marketing'
|
||||
},
|
||||
{
|
||||
icon: <Palette className="w-5 h-5" />,
|
||||
@@ -78,7 +78,7 @@ export default function MorePage() {
|
||||
<button
|
||||
key={item.to}
|
||||
onClick={() => navigate(item.to)}
|
||||
className="w-full flex items-center gap-4 py-4 hover:bg-accent transition-colors"
|
||||
className="w-full flex items-center gap-4 py-4 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
{item.icon}
|
||||
@@ -102,11 +102,10 @@ export default function MorePage() {
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
|
||||
theme === option.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${theme === option.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
{option.icon}
|
||||
<span className="text-xs font-medium">{option.label}</span>
|
||||
|
||||
@@ -1,11 +1,267 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Attribute {
|
||||
attribute_id: number;
|
||||
attribute_name: string;
|
||||
attribute_label: string;
|
||||
attribute_type: string;
|
||||
attribute_orderby: string;
|
||||
attribute_public: number;
|
||||
}
|
||||
|
||||
export default function ProductAttributes() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingAttribute, setEditingAttribute] = useState<Attribute | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
label: '',
|
||||
type: 'select',
|
||||
orderby: 'menu_order',
|
||||
public: 1
|
||||
});
|
||||
|
||||
const { data: attributes = [], isLoading } = useQuery<Attribute[]>({
|
||||
queryKey: ['product-attributes'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${api.root()}/products/attributes`, {
|
||||
headers: { 'X-WP-Nonce': api.nonce() },
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/products/attributes', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||
toast.success(__('Attribute created successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create attribute'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/attributes/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||
toast.success(__('Attribute updated successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update attribute'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/products/attributes/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||
toast.success(__('Attribute deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to delete attribute'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenDialog = (attribute?: Attribute) => {
|
||||
if (attribute) {
|
||||
setEditingAttribute(attribute);
|
||||
setFormData({
|
||||
name: attribute.attribute_name,
|
||||
label: attribute.attribute_label,
|
||||
type: attribute.attribute_type,
|
||||
orderby: attribute.attribute_orderby,
|
||||
public: attribute.attribute_public,
|
||||
});
|
||||
} else {
|
||||
setEditingAttribute(null);
|
||||
setFormData({ name: '', label: '', type: 'select', orderby: 'menu_order', public: 1 });
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingAttribute(null);
|
||||
setFormData({ name: '', label: '', type: 'select', orderby: 'menu_order', public: 1 });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingAttribute) {
|
||||
updateMutation.mutate({ id: editingAttribute.attribute_id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(__('Are you sure you want to delete this attribute?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAttributes = attributes.filter((attr) =>
|
||||
attr.attribute_label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
attr.attribute_name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Product Attributes')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA attributes manager.')}</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{__('Product Attributes')}</h1>
|
||||
<Button onClick={() => handleOpenDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Attribute')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search attributes...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">{__('Loading attributes...')}</p>
|
||||
</div>
|
||||
) : filteredAttributes.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">{__('No attributes found')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium">{__('Name')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Slug')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Type')}</th>
|
||||
<th className="text-center p-4 font-medium">{__('Order By')}</th>
|
||||
<th className="text-right p-4 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAttributes.map((attribute, index) => (
|
||||
<tr key={attribute.attribute_id || `attribute-${index}`} className="border-t hover:bg-muted/30">
|
||||
<td className="p-4 font-medium">{attribute.attribute_label}</td>
|
||||
<td className="p-4 text-muted-foreground">{attribute.attribute_name}</td>
|
||||
<td className="p-4 text-sm capitalize">{attribute.attribute_type}</td>
|
||||
<td className="p-4 text-center text-sm">{attribute.attribute_orderby}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(attribute)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(attribute.attribute_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingAttribute ? __('Edit Attribute') : __('Add Attribute')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingAttribute ? __('Update attribute information') : __('Create a new product attribute')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="label">{__('Label')}</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder={__('e.g., Color, Size')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">{__('Slug')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={__('Leave empty to auto-generate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">{__('Type')}</Label>
|
||||
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="select">{__('Select')}</SelectItem>
|
||||
<SelectItem value="text">{__('Text')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="orderby">{__('Default Sort Order')}</Label>
|
||||
<Select value={formData.orderby} onValueChange={(value) => setFormData({ ...formData, orderby: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="menu_order">{__('Custom ordering')}</SelectItem>
|
||||
<SelectItem value="name">{__('Name')}</SelectItem>
|
||||
<SelectItem value="name_num">{__('Name (numeric)')}</SelectItem>
|
||||
<SelectItem value="id">{__('Term ID')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingAttribute ? __('Update') : __('Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,242 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Category {
|
||||
term_id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
count: number;
|
||||
parent: number;
|
||||
}
|
||||
|
||||
export default function ProductCategories() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', slug: '', description: '', parent: 0 });
|
||||
|
||||
const { data: categories = [], isLoading } = useQuery<Category[]>({
|
||||
queryKey: ['product-categories'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${api.root()}/products/categories`, {
|
||||
headers: { 'X-WP-Nonce': api.nonce() },
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/products/categories', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||
toast.success(__('Category created successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create category'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/categories/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||
toast.success(__('Category updated successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update category'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/products/categories/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||
toast.success(__('Category deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to delete category'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenDialog = (category?: Category) => {
|
||||
if (category) {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description || '',
|
||||
parent: category.parent || 0,
|
||||
});
|
||||
} else {
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: '', slug: '', description: '', parent: 0 });
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: '', slug: '', description: '', parent: 0 });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingCategory) {
|
||||
updateMutation.mutate({ id: editingCategory.term_id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(__('Are you sure you want to delete this category?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCategories = categories.filter((cat) =>
|
||||
cat.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Product Categories')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA categories manager.')}</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{__('Product Categories')}</h1>
|
||||
<Button onClick={() => handleOpenDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Category')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search categories...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">{__('Loading categories...')}</p>
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">{__('No categories found')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium">{__('Name')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Slug')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Description')}</th>
|
||||
<th className="text-center p-4 font-medium">{__('Count')}</th>
|
||||
<th className="text-right p-4 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCategories.map((category, index) => (
|
||||
<tr key={category.term_id || `category-${index}`} className="border-t hover:bg-muted/30">
|
||||
<td className="p-4 font-medium">{category.name}</td>
|
||||
<td className="p-4 text-muted-foreground">{category.slug}</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{category.description || '-'}
|
||||
</td>
|
||||
<td className="p-4 text-center">{category.count}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(category)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(category.term_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCategory ? __('Edit Category') : __('Add Category')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingCategory ? __('Update category information') : __('Create a new product category')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{__('Name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">{__('Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder={__('Leave empty to auto-generate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{__('Description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingCategory ? __('Update') : __('Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export default function ProductEdit() {
|
||||
onSubmit={handleSubmit}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
productId={product.id}
|
||||
/>
|
||||
|
||||
{/* Level 1 compatibility: Custom meta fields from plugins */}
|
||||
|
||||
@@ -1,11 +1,240 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Tag {
|
||||
term_id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function ProductTags() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', slug: '', description: '' });
|
||||
|
||||
const { data: tags = [], isLoading } = useQuery<Tag[]>({
|
||||
queryKey: ['product-tags'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${api.root()}/products/tags`, {
|
||||
headers: { 'X-WP-Nonce': api.nonce() },
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/products/tags', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||
toast.success(__('Tag created successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create tag'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/tags/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||
toast.success(__('Tag updated successfully'));
|
||||
handleCloseDialog();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to update tag'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/products/tags/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||
toast.success(__('Tag deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to delete tag'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenDialog = (tag?: Tag) => {
|
||||
if (tag) {
|
||||
setEditingTag(tag);
|
||||
setFormData({
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || '',
|
||||
});
|
||||
} else {
|
||||
setEditingTag(null);
|
||||
setFormData({ name: '', slug: '', description: '' });
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingTag(null);
|
||||
setFormData({ name: '', slug: '', description: '' });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingTag) {
|
||||
updateMutation.mutate({ id: editingTag.term_id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(__('Are you sure you want to delete this tag?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTags = tags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-3">{__('Product Tags')}</h1>
|
||||
<p className="opacity-70">{__('Coming soon — SPA tags manager.')}</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{__('Product Tags')}</h1>
|
||||
<Button onClick={() => handleOpenDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Tag')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search tags...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">{__('Loading tags...')}</p>
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 border rounded-lg">
|
||||
<p className="text-muted-foreground">{__('No tags found')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium">{__('Name')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Slug')}</th>
|
||||
<th className="text-left p-4 font-medium">{__('Description')}</th>
|
||||
<th className="text-center p-4 font-medium">{__('Count')}</th>
|
||||
<th className="text-right p-4 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTags.map((tag, index) => (
|
||||
<tr key={tag.term_id || `tag-${index}`} className="border-t hover:bg-muted/30">
|
||||
<td className="p-4 font-medium">{tag.name}</td>
|
||||
<td className="p-4 text-muted-foreground">{tag.slug}</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{tag.description || '-'}
|
||||
</td>
|
||||
<td className="p-4 text-center">{tag.count}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(tag)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tag.term_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingTag ? __('Edit Tag') : __('Add Tag')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingTag ? __('Update tag information') : __('Create a new product tag')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{__('Name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">{__('Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder={__('Leave empty to auto-generate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{__('Description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingTag ? __('Update') : __('Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Copy, Check, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface DirectCartLinksProps {
|
||||
productId: number;
|
||||
productType: 'simple' | 'variable';
|
||||
variations?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
attributes: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function DirectCartLinks({ productId, productType, variations = [] }: DirectCartLinksProps) {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store'; // This should ideally come from settings
|
||||
|
||||
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('add-to-cart', productId.toString());
|
||||
if (variationId) {
|
||||
params.set('variation_id', variationId.toString());
|
||||
}
|
||||
if (quantity > 1) {
|
||||
params.set('quantity', quantity.toString());
|
||||
}
|
||||
params.set('redirect', redirect);
|
||||
|
||||
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (link: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopiedLink(link);
|
||||
toast.success(`${label} link copied!`);
|
||||
setTimeout(() => setCopiedLink(null), 2000);
|
||||
} catch (err) {
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
};
|
||||
|
||||
const LinkRow = ({
|
||||
label,
|
||||
link,
|
||||
description
|
||||
}: {
|
||||
label: string;
|
||||
link: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const isCopied = copiedLink === link;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(link, label)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => window.open(link, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={link}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Direct-to-Cart Links</CardTitle>
|
||||
<CardDescription>
|
||||
Generate copyable links that add this product to cart and redirect to cart or checkout page.
|
||||
Perfect for landing pages, email campaigns, and social media.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-quantity">Default Quantity</Label>
|
||||
<Input
|
||||
id="link-quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set quantity to 1 to exclude from URL (cleaner links)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Simple Product Links */}
|
||||
{productType === 'simple' && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h4 className="font-medium">Simple Product Links</h4>
|
||||
</div>
|
||||
|
||||
<LinkRow
|
||||
label="Add to Cart"
|
||||
link={generateLink(undefined, 'cart')}
|
||||
description="Adds product to cart and shows cart page"
|
||||
/>
|
||||
|
||||
<LinkRow
|
||||
label="Direct to Checkout"
|
||||
link={generateLink(undefined, 'checkout')}
|
||||
description="Adds product to cart and goes directly to checkout"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variable Product Links */}
|
||||
{productType === 'variable' && variations.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h4 className="font-medium">Variable Product Links</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{variations.length} variation(s) - Select a variation to generate links
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{variations.map((variation, index) => (
|
||||
<details key={variation.id} className="group border rounded-lg">
|
||||
<summary className="cursor-pointer p-3 hover:bg-muted/50 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-sm">{variation.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(ID: {variation.id})
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div className="p-4 pt-0 space-y-3 border-t">
|
||||
<LinkRow
|
||||
label="Add to Cart"
|
||||
link={generateLink(variation.id, 'cart')}
|
||||
/>
|
||||
|
||||
<LinkRow
|
||||
label="Direct to Checkout"
|
||||
link={generateLink(variation.id, 'checkout')}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL Parameters Reference */}
|
||||
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium text-sm mb-2">URL Parameters Reference</h4>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div><code className="bg-background px-1 py-0.5 rounded">add-to-cart</code> - Product ID (required)</div>
|
||||
<div><code className="bg-background px-1 py-0.5 rounded">variation_id</code> - Variation ID (for variable products)</div>
|
||||
<div><code className="bg-background px-1 py-0.5 rounded">quantity</code> - Quantity (default: 1)</div>
|
||||
<div><code className="bg-background px-1 py-0.5 rounded">redirect</code> - Destination: <code>cart</code> or <code>checkout</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
className?: string;
|
||||
formRef?: React.RefObject<HTMLFormElement>;
|
||||
hideSubmitButton?: boolean;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
export function ProductFormTabbed({
|
||||
@@ -50,6 +51,7 @@ export function ProductFormTabbed({
|
||||
className,
|
||||
formRef,
|
||||
hideSubmitButton = false,
|
||||
productId,
|
||||
}: Props) {
|
||||
// Form state
|
||||
const [name, setName] = useState(initial?.name || '');
|
||||
@@ -225,6 +227,7 @@ export function ProductFormTabbed({
|
||||
variations={variations}
|
||||
setVariations={setVariations}
|
||||
regularPrice={regularPrice}
|
||||
productId={productId}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Plus, X, Layers, Image as ImageIcon } from 'lucide-react';
|
||||
import { Plus, X, Layers, Image as ImageIcon, Copy, Check, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getStoreCurrency } from '@/lib/currency';
|
||||
import { openWPMediaImage } from '@/lib/wp-media';
|
||||
@@ -30,6 +30,7 @@ type VariationsTabProps = {
|
||||
variations: ProductVariant[];
|
||||
setVariations: (value: ProductVariant[]) => void;
|
||||
regularPrice: string;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
export function VariationsTab({
|
||||
@@ -38,8 +39,33 @@ export function VariationsTab({
|
||||
variations,
|
||||
setVariations,
|
||||
regularPrice,
|
||||
productId,
|
||||
}: VariationsTabProps) {
|
||||
const store = getStoreCurrency();
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store';
|
||||
|
||||
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
if (!productId) return '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('add-to-cart', productId.toString());
|
||||
params.set('variation_id', variationId.toString());
|
||||
params.set('redirect', redirect);
|
||||
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (link: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopiedLink(link);
|
||||
toast.success(`${label} link copied!`);
|
||||
setTimeout(() => setCopiedLink(null), 2000);
|
||||
} catch (err) {
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
};
|
||||
|
||||
const addAttribute = () => {
|
||||
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
||||
@@ -305,6 +331,45 @@ export function VariationsTab({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Direct Cart Links */}
|
||||
{productId && variation.id && (
|
||||
<div className="mt-4 pt-4 border-t space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{__('Direct-to-Cart Links')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(generateLink(variation.id!, 'cart'), 'Cart')}
|
||||
className="flex-1"
|
||||
>
|
||||
{copiedLink === generateLink(variation.id!, 'cart') ? (
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{__('Copy Cart Link')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(generateLink(variation.id!, 'checkout'), 'Checkout')}
|
||||
className="flex-1"
|
||||
>
|
||||
{copiedLink === generateLink(variation.id!, 'checkout') ? (
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{__('Copy Checkout Link')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, CheckCircle, AlertCircle, Eye, EyeOff, Lock } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const key = searchParams.get('key') || '';
|
||||
const login = searchParams.get('login') || '';
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Validate the reset key on mount
|
||||
useEffect(() => {
|
||||
const validateKey = async () => {
|
||||
if (!key || !login) {
|
||||
setError(__('Invalid password reset link. Please request a new one.'));
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/validate-reset-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, login }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.valid) {
|
||||
setIsValid(true);
|
||||
} else {
|
||||
setError(data.message || __('This password reset link has expired or is invalid. Please request a new one.'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(__('Unable to validate reset link. Please try again later.'));
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateKey();
|
||||
}, [key, login]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError(__('Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (password.length < 8) {
|
||||
setError(__('Password must be at least 8 characters long'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, login, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(data.message || __('Failed to reset password. Please try again.'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(__('An error occurred. Please try again later.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Password strength indicator
|
||||
const getPasswordStrength = (pwd: string) => {
|
||||
if (pwd.length === 0) return { label: '', color: '' };
|
||||
if (pwd.length < 8) return { label: __('Too short'), color: 'text-red-500' };
|
||||
|
||||
let strength = 0;
|
||||
if (pwd.length >= 8) strength++;
|
||||
if (pwd.length >= 12) strength++;
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
|
||||
if (/\d/.test(pwd)) strength++;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
|
||||
|
||||
if (strength <= 2) return { label: __('Weak'), color: 'text-orange-500' };
|
||||
if (strength <= 3) return { label: __('Medium'), color: 'text-yellow-500' };
|
||||
return { label: __('Strong'), color: 'text-green-500' };
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(password);
|
||||
|
||||
// Loading state
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||
<p className="text-muted-foreground">{__('Validating reset link...')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center py-8">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">{__('Password Reset Successful')}</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
{__('Your password has been updated. You can now log in with your new password.')}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/login')}>
|
||||
{__('Go to Login')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state (invalid key)
|
||||
if (!isValid && error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">{__('Invalid Reset Link')}</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">{error}</p>
|
||||
<Button variant="outline" onClick={() => window.location.href = window.WNW_CONFIG?.siteUrl + '/my-account/lost-password/'}>
|
||||
{__('Request New Reset Link')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">{__('Reset Your Password')}</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{__('Enter your new password below')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{__('New Password')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={__('Enter new password')}
|
||||
required
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{password && (
|
||||
<p className={`text-sm ${passwordStrength.color}`}>
|
||||
{__('Strength')}: {passwordStrength.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">{__('Confirm Password')}</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={__('Confirm new password')}
|
||||
required
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="text-sm text-red-500">{__('Passwords do not match')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{__('Resetting...')}
|
||||
</>
|
||||
) : (
|
||||
__('Reset Password')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||
interface CustomerSettings {
|
||||
auto_register_members: boolean;
|
||||
multiple_addresses_enabled: boolean;
|
||||
wishlist_enabled: boolean;
|
||||
vip_min_spent: number;
|
||||
vip_min_orders: number;
|
||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||
@@ -25,7 +24,6 @@ export default function CustomersSettings() {
|
||||
const [settings, setSettings] = useState<CustomerSettings>({
|
||||
auto_register_members: false,
|
||||
multiple_addresses_enabled: true,
|
||||
wishlist_enabled: true,
|
||||
vip_min_spent: 1000,
|
||||
vip_min_orders: 10,
|
||||
vip_timeframe: 'all',
|
||||
@@ -140,13 +138,7 @@ export default function CustomersSettings() {
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
id="wishlist_enabled"
|
||||
label={__('Enable wishlist')}
|
||||
description={__('Allow customers to save products to their wishlist for later purchase. Customers can add products to wishlist from product cards and manage them in their account.')}
|
||||
checked={settings.wishlist_enabled}
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, wishlist_enabled: checked })}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
|
||||
148
admin-spa/src/routes/Settings/ModuleSettings.tsx
Normal file
148
admin-spa/src/routes/Settings/ModuleSettings.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { SchemaForm, FormSchema } from '@/components/forms/SchemaForm';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
has_settings: boolean;
|
||||
settings_component?: string;
|
||||
}
|
||||
|
||||
export default function ModuleSettings() {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useModuleSettings(moduleId || '');
|
||||
|
||||
// Fetch module info
|
||||
const { data: modulesData } = useQuery({
|
||||
queryKey: ['modules'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules');
|
||||
return response as { modules: Record<string, Module> };
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch settings schema
|
||||
const { data: schemaData } = useQuery({
|
||||
queryKey: ['module-schema', moduleId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/modules/${moduleId}/schema`);
|
||||
return response as { schema: FormSchema };
|
||||
},
|
||||
enabled: !!moduleId,
|
||||
});
|
||||
|
||||
const module = modulesData?.modules?.[moduleId || ''];
|
||||
|
||||
if (!module) {
|
||||
return (
|
||||
<SettingsLayout title={__('Module Settings')} isLoading={!modulesData}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">{__('Module not found')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mt-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!module.has_settings) {
|
||||
return (
|
||||
<SettingsLayout title={module.label}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
{__('This module does not have any settings')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mt-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// If module has custom component, load it dynamically
|
||||
if (module.settings_component) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`${module.label} ${__('Settings')}`}
|
||||
description={module.description}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
|
||||
<DynamicComponentLoader
|
||||
componentUrl={module.settings_component}
|
||||
moduleId={moduleId || ''}
|
||||
/>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render schema-based form
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`${module.label} ${__('Settings')}`}
|
||||
description={module.description}
|
||||
isLoading={settingsLoading}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
|
||||
<SettingsCard
|
||||
title={__('Configuration')}
|
||||
description={__('Configure module settings below')}
|
||||
>
|
||||
{schemaData?.schema ? (
|
||||
<SchemaForm
|
||||
schema={schemaData.schema}
|
||||
initialValues={settings}
|
||||
onSubmit={(values) => updateSettings.mutate(values)}
|
||||
isSubmitting={updateSettings.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>{__('No settings schema available for this module')}</p>
|
||||
<p className="text-xs mt-2">
|
||||
{__('The module developer needs to register a settings schema using the woonoow/module_settings_schema filter')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Mail, Heart, Users, RefreshCcw, Key } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Mail, Heart, Users, RefreshCcw, Key, Search, Settings, Truck, CreditCard, BarChart3, Puzzle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -17,19 +20,23 @@ interface Module {
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
features: string[];
|
||||
is_addon?: boolean;
|
||||
version?: string;
|
||||
author?: string;
|
||||
has_settings?: boolean;
|
||||
}
|
||||
|
||||
interface ModulesData {
|
||||
modules: Record<string, Module>;
|
||||
grouped: {
|
||||
marketing: Module[];
|
||||
customers: Module[];
|
||||
products: Module[];
|
||||
};
|
||||
grouped: Record<string, Module[]>;
|
||||
categories: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function Modules() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const { data: modulesData, isLoading } = useQuery<ModulesData>({
|
||||
queryKey: ['modules'],
|
||||
@@ -64,21 +71,45 @@ export default function Modules() {
|
||||
users: Users,
|
||||
'refresh-cw': RefreshCcw,
|
||||
key: Key,
|
||||
truck: Truck,
|
||||
'credit-card': CreditCard,
|
||||
'bar-chart-3': BarChart3,
|
||||
puzzle: Puzzle,
|
||||
};
|
||||
const Icon = icons[iconName] || Mail;
|
||||
const Icon = icons[iconName] || Puzzle;
|
||||
return <Icon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
marketing: __('Marketing & Sales'),
|
||||
customers: __('Customer Experience'),
|
||||
products: __('Products & Inventory'),
|
||||
};
|
||||
return labels[category] || category;
|
||||
};
|
||||
// Filter modules based on search and category
|
||||
const filteredGrouped = useMemo(() => {
|
||||
if (!modulesData?.grouped) return {};
|
||||
|
||||
const categories = ['marketing', 'customers', 'products'];
|
||||
const filtered: Record<string, Module[]> = {};
|
||||
|
||||
Object.entries(modulesData.grouped).forEach(([category, modules]) => {
|
||||
// Filter by category if selected
|
||||
if (selectedCategory && category !== selectedCategory) return;
|
||||
|
||||
// Filter by search query
|
||||
const matchingModules = modules.filter((module) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
module.label.toLowerCase().includes(query) ||
|
||||
module.description.toLowerCase().includes(query) ||
|
||||
module.features.some((f) => f.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
if (matchingModules.length > 0) {
|
||||
filtered[category] = matchingModules;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [modulesData, searchQuery, selectedCategory]);
|
||||
|
||||
const categories = Object.keys(modulesData?.categories || {});
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
@@ -86,6 +117,41 @@ export default function Modules() {
|
||||
description={__('Enable or disable features to customize your store')}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{/* Search and Filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search modules...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === null ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
>
|
||||
{__('All Categories')}
|
||||
</Button>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{modulesData?.categories[category] || category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm space-y-2">
|
||||
@@ -101,15 +167,21 @@ export default function Modules() {
|
||||
</div>
|
||||
|
||||
{/* Module Categories */}
|
||||
{categories.map((category) => {
|
||||
const modules = modulesData?.grouped[category as keyof typeof modulesData.grouped] || [];
|
||||
{Object.keys(filteredGrouped).length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Search className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>{__('No modules found matching your search')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(filteredGrouped).map(([category, modules]) => {
|
||||
|
||||
if (modules.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
key={category}
|
||||
title={getCategoryLabel(category)}
|
||||
title={modulesData?.categories[category] || category}
|
||||
description={__('Manage modules in this category')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -138,6 +210,11 @@ export default function Modules() {
|
||||
{__('Active')}
|
||||
</Badge>
|
||||
)}
|
||||
{module.is_addon && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{__('Addon')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{module.description}
|
||||
@@ -159,8 +236,21 @@ export default function Modules() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<div className="flex items-center">
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Settings Gear Icon */}
|
||||
{module.has_settings && module.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/settings/modules/${module.id}`)}
|
||||
title={__('Module Settings')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<Switch
|
||||
checked={module.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
|
||||
@@ -38,54 +38,6 @@ export default function EditTemplate() {
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||
const [activeTab, setActiveTab] = useState('preview');
|
||||
|
||||
// All available template variables
|
||||
const availableVariables = [
|
||||
// Order variables
|
||||
'order_number',
|
||||
'order_id',
|
||||
'order_date',
|
||||
'order_total',
|
||||
'order_subtotal',
|
||||
'order_tax',
|
||||
'order_shipping',
|
||||
'order_discount',
|
||||
'order_status',
|
||||
'order_url',
|
||||
'order_items_table',
|
||||
'completion_date',
|
||||
'estimated_delivery',
|
||||
// Customer variables
|
||||
'customer_name',
|
||||
'customer_first_name',
|
||||
'customer_last_name',
|
||||
'customer_email',
|
||||
'customer_phone',
|
||||
'billing_address',
|
||||
'shipping_address',
|
||||
// Payment variables
|
||||
'payment_method',
|
||||
'payment_status',
|
||||
'payment_date',
|
||||
'transaction_id',
|
||||
'payment_retry_url',
|
||||
// Shipping/Tracking variables
|
||||
'tracking_number',
|
||||
'tracking_url',
|
||||
'shipping_carrier',
|
||||
'shipping_method',
|
||||
// URL variables
|
||||
'review_url',
|
||||
'shop_url',
|
||||
'my_account_url',
|
||||
// Store variables
|
||||
'site_name',
|
||||
'site_title',
|
||||
'store_name',
|
||||
'store_url',
|
||||
'support_email',
|
||||
'current_year',
|
||||
];
|
||||
|
||||
// Fetch email customization settings
|
||||
const { data: emailSettings } = useQuery({
|
||||
queryKey: ['email-settings'],
|
||||
@@ -176,8 +128,10 @@ export default function EditTemplate() {
|
||||
setBlocks(newBlocks); // Keep blocks in sync
|
||||
};
|
||||
|
||||
// Variable keys for the rich text editor dropdown
|
||||
const variableKeys = availableVariables;
|
||||
// Variable keys for the rich text editor dropdown - from API (contextual per event)
|
||||
const variableKeys = template?.available_variables
|
||||
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
|
||||
: [];
|
||||
|
||||
// Parse [card] tags and [button] shortcodes for preview
|
||||
const parseCardsForPreview = (content: string) => {
|
||||
@@ -310,6 +264,15 @@ export default function EditTemplate() {
|
||||
store_url: '#',
|
||||
store_email: 'store@example.com',
|
||||
support_email: 'support@example.com',
|
||||
// Account-related URLs and variables
|
||||
login_url: '#',
|
||||
reset_link: '#',
|
||||
reset_key: 'abc123xyz',
|
||||
user_login: 'johndoe',
|
||||
user_email: 'john@example.com',
|
||||
user_temp_password: '••••••••',
|
||||
customer_first_name: 'John',
|
||||
customer_last_name: 'Doe',
|
||||
};
|
||||
|
||||
Object.keys(sampleData).forEach((key) => {
|
||||
@@ -318,16 +281,13 @@ export default function EditTemplate() {
|
||||
});
|
||||
|
||||
// Highlight variables that don't have sample data
|
||||
availableVariables.forEach(key => {
|
||||
// Use plain text [variable] instead of HTML spans to avoid breaking href attributes
|
||||
variableKeys.forEach((key: string) => {
|
||||
if (!storeVariables[key] && !sampleData[key]) {
|
||||
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
|
||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), `[${key}]`);
|
||||
}
|
||||
});
|
||||
|
||||
// Parse [card] tags
|
||||
previewBody = parseCardsForPreview(previewBody);
|
||||
|
||||
// Get email settings for preview
|
||||
const settings = emailSettings || {};
|
||||
const primaryColor = settings.primary_color || '#7f54b3';
|
||||
@@ -380,14 +340,13 @@ export default function EditTemplate() {
|
||||
.header { padding: 20px 16px; }
|
||||
.footer { padding: 20px 16px; }
|
||||
}
|
||||
.card-success { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-success * { color: ${heroTextColor} !important; }
|
||||
.card-success { background-color: #f0fdf4; }
|
||||
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-highlight * { color: ${heroTextColor} !important; }
|
||||
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-hero * { color: ${heroTextColor} !important; }
|
||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
||||
.card-info { background-color: #f0f7ff; }
|
||||
.card-warning { background-color: #fff8e1; }
|
||||
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
@@ -492,91 +451,91 @@ export default function EditTemplate() {
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,11 +153,11 @@ export default function TemplateEditor({
|
||||
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||
.card-gutter { padding: 0 16px; }
|
||||
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
|
||||
.card-success { background: #e8f5e9; border: 1px solid #4caf50; }
|
||||
.card-success { background-color: #f0fdf4; }
|
||||
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||
.card-highlight * { color: #fff !important; }
|
||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
||||
.card-info { background-color: #f0f7ff; }
|
||||
.card-warning { background-color: #fff8e1; }
|
||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '1rem'
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")]
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
important: '#woonoow-admin-app',
|
||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
||||
theme: {
|
||||
container: { center: true, padding: "1rem" },
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: { app: 'src/main.tsx' },
|
||||
output: { entryFileNames: 'app.js', assetFileNames: 'app.[ext]' }
|
||||
|
||||
106
build-production.sh
Executable file
106
build-production.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# WooNooW Plugin - Production Build Script
|
||||
# This script creates a production-ready zip file of the plugin
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
PLUGIN_NAME="woonoow"
|
||||
VERSION=$(grep "Version:" woonoow.php | awk '{print $3}')
|
||||
BUILD_DIR="build"
|
||||
DIST_DIR="dist"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
ZIP_NAME="${PLUGIN_NAME}-${VERSION}-${TIMESTAMP}.zip"
|
||||
|
||||
echo "=========================================="
|
||||
echo "WooNooW Production Build"
|
||||
echo "=========================================="
|
||||
echo "Plugin: ${PLUGIN_NAME}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Timestamp: ${TIMESTAMP}"
|
||||
echo "=========================================="
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf ${BUILD_DIR}
|
||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}
|
||||
mkdir -p ${DIST_DIR}
|
||||
|
||||
# Copy plugin files
|
||||
echo "Copying plugin files..."
|
||||
rsync -av --progress \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.git' \
|
||||
--exclude='.gitignore' \
|
||||
--exclude='build' \
|
||||
--exclude='dist' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='customer-spa' \
|
||||
--exclude='admin-spa' \
|
||||
--exclude='examples' \
|
||||
--exclude='*.sh' \
|
||||
--exclude='*.md' \
|
||||
--exclude='archive' \
|
||||
--exclude='test-*.php' \
|
||||
--exclude='check-*.php' \
|
||||
./ ${BUILD_DIR}/${PLUGIN_NAME}/
|
||||
|
||||
# Verify production builds exist in source before copying
|
||||
echo "Verifying production builds..."
|
||||
if [ ! -f "customer-spa/dist/app.js" ]; then
|
||||
echo "ERROR: Customer SPA production build not found!"
|
||||
echo "Please run: cd customer-spa && npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "admin-spa/dist/app.js" ]; then
|
||||
echo "ERROR: Admin SPA production build not found!"
|
||||
echo "Please run: cd admin-spa && npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Customer SPA build verified ($(du -h customer-spa/dist/app.js | cut -f1))"
|
||||
echo "✓ Admin SPA build verified ($(du -h admin-spa/dist/app.js | cut -f1))"
|
||||
|
||||
# Copy only essential SPA build files
|
||||
echo "Copying SPA build files..."
|
||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist
|
||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist
|
||||
|
||||
# Customer SPA - app.js, app.css, and fonts
|
||||
cp customer-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
||||
cp customer-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
||||
if [ -d "customer-spa/dist/fonts" ]; then
|
||||
cp -r customer-spa/dist/fonts ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
||||
echo "✓ Copied customer-spa fonts"
|
||||
fi
|
||||
|
||||
# Admin SPA - app.js and app.css
|
||||
cp admin-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
|
||||
cp admin-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
|
||||
|
||||
echo "✓ Copied customer-spa: app.js ($(du -h customer-spa/dist/app.js | cut -f1)), app.css ($(du -h customer-spa/dist/app.css | cut -f1))"
|
||||
echo "✓ Copied admin-spa: app.js ($(du -h admin-spa/dist/app.js | cut -f1)), app.css ($(du -h admin-spa/dist/app.css | cut -f1))"
|
||||
|
||||
# Create zip file
|
||||
echo "Creating zip file..."
|
||||
cd ${BUILD_DIR}
|
||||
zip -r ../${DIST_DIR}/${ZIP_NAME} ${PLUGIN_NAME} -q
|
||||
cd ..
|
||||
|
||||
# Calculate file size
|
||||
FILE_SIZE=$(du -h ${DIST_DIR}/${ZIP_NAME} | cut -f1)
|
||||
|
||||
echo "=========================================="
|
||||
echo "✓ Production build complete!"
|
||||
echo "=========================================="
|
||||
echo "File: ${DIST_DIR}/${ZIP_NAME}"
|
||||
echo "Size: ${FILE_SIZE}"
|
||||
echo "=========================================="
|
||||
|
||||
# Clean up build directory
|
||||
echo "Cleaning up..."
|
||||
rm -rf ${BUILD_DIR}
|
||||
|
||||
echo "Done! 🚀"
|
||||
98
check-shop-page.php
Normal file
98
check-shop-page.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* Diagnostic script to check Shop page configuration
|
||||
* Upload this to your WordPress root and access via browser
|
||||
*/
|
||||
|
||||
// Load WordPress
|
||||
require_once(__DIR__ . '/../../../wp-load.php');
|
||||
|
||||
if (!current_user_can('manage_options')) {
|
||||
die('Access denied');
|
||||
}
|
||||
|
||||
echo '<h1>WooNooW Shop Page Diagnostic</h1>';
|
||||
|
||||
// 1. Check WooCommerce Shop Page ID
|
||||
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||
echo '<h2>1. WooCommerce Shop Page Setting</h2>';
|
||||
echo '<p>Shop Page ID: ' . ($shop_page_id ? $shop_page_id : 'NOT SET') . '</p>';
|
||||
|
||||
if ($shop_page_id) {
|
||||
$shop_page = get_post($shop_page_id);
|
||||
if ($shop_page) {
|
||||
echo '<p>Shop Page Title: ' . esc_html($shop_page->post_title) . '</p>';
|
||||
echo '<p>Shop Page Status: ' . esc_html($shop_page->post_status) . '</p>';
|
||||
echo '<p>Shop Page URL: ' . get_permalink($shop_page_id) . '</p>';
|
||||
echo '<h3>Shop Page Content:</h3>';
|
||||
echo '<pre>' . esc_html($shop_page->post_content) . '</pre>';
|
||||
|
||||
// Check for shortcode
|
||||
if (has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||
echo '<p style="color: green;">✓ Has [woonoow_shop] shortcode</p>';
|
||||
} else {
|
||||
echo '<p style="color: red;">✗ Missing [woonoow_shop] shortcode</p>';
|
||||
}
|
||||
} else {
|
||||
echo '<p style="color: red;">ERROR: Shop page not found!</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Find all pages with woonoow shortcodes
|
||||
echo '<h2>2. Pages with WooNooW Shortcodes</h2>';
|
||||
$pages_with_shortcodes = get_posts([
|
||||
'post_type' => 'page',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => -1,
|
||||
's' => 'woonoow_',
|
||||
]);
|
||||
|
||||
if (empty($pages_with_shortcodes)) {
|
||||
echo '<p style="color: orange;">No pages found with woonoow_ shortcodes</p>';
|
||||
} else {
|
||||
echo '<ul>';
|
||||
foreach ($pages_with_shortcodes as $page) {
|
||||
echo '<li>';
|
||||
echo '<strong>' . esc_html($page->post_title) . '</strong> (ID: ' . $page->ID . ')<br>';
|
||||
echo 'URL: ' . get_permalink($page->ID) . '<br>';
|
||||
echo 'Content: <pre>' . esc_html(substr($page->post_content, 0, 200)) . '</pre>';
|
||||
echo '</li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
|
||||
// 3. Check Customer SPA Settings
|
||||
echo '<h2>3. Customer SPA Settings</h2>';
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
echo '<pre>' . print_r($spa_settings, true) . '</pre>';
|
||||
|
||||
// 4. Check if pages were created by installer
|
||||
echo '<h2>4. WooNooW Page Options</h2>';
|
||||
$woonoow_pages = [
|
||||
'shop' => get_option('woonoow_shop_page_id'),
|
||||
'cart' => get_option('woonoow_cart_page_id'),
|
||||
'checkout' => get_option('woonoow_checkout_page_id'),
|
||||
'account' => get_option('woonoow_account_page_id'),
|
||||
];
|
||||
|
||||
foreach ($woonoow_pages as $key => $page_id) {
|
||||
echo '<p>' . ucfirst($key) . ' Page ID: ' . ($page_id ? $page_id : 'NOT SET');
|
||||
if ($page_id) {
|
||||
$page = get_post($page_id);
|
||||
if ($page) {
|
||||
echo ' - ' . esc_html($page->post_title) . ' (' . $page->post_status . ')';
|
||||
} else {
|
||||
echo ' - <span style="color: red;">PAGE NOT FOUND</span>';
|
||||
}
|
||||
}
|
||||
echo '</p>';
|
||||
}
|
||||
|
||||
echo '<hr>';
|
||||
echo '<h2>Recommended Actions:</h2>';
|
||||
echo '<ol>';
|
||||
echo '<li>If Shop page doesn\'t have [woonoow_shop] shortcode, add it to the page content</li>';
|
||||
echo '<li>If Shop page ID doesn\'t match WooCommerce setting, update WooCommerce > Settings > Products > Shop Page</li>';
|
||||
echo '<li>If SPA mode is "disabled", it will only load on pages with shortcodes</li>';
|
||||
echo '<li>If SPA mode is "full", it will load on all WooCommerce pages</li>';
|
||||
echo '</ol>';
|
||||
20
composer.lock
generated
Normal file
20
composer.lock
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c8dfaf9b12dfc28774a5f4e2e71e84af",
|
||||
"packages": [],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
2
customer-spa/package-lock.json
generated
2
customer-spa/package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
||||
@@ -14,6 +14,10 @@ import Cart from './pages/Cart';
|
||||
import Checkout from './pages/Checkout';
|
||||
import ThankYou from './pages/ThankYou';
|
||||
import Account from './pages/Account';
|
||||
import Wishlist from './pages/Wishlist';
|
||||
import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
|
||||
// Create QueryClient instance
|
||||
const queryClient = new QueryClient({
|
||||
@@ -45,36 +49,73 @@ const getThemeConfig = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Get appearance settings from window
|
||||
const getAppearanceSettings = () => {
|
||||
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
||||
};
|
||||
|
||||
// Get initial route from data attribute (set by PHP based on SPA mode)
|
||||
const getInitialRoute = () => {
|
||||
const appEl = document.getElementById('woonoow-customer-app');
|
||||
const initialRoute = appEl?.getAttribute('data-initial-route');
|
||||
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
|
||||
console.log('[WooNooW Customer] App element:', appEl);
|
||||
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
|
||||
return initialRoute || '/shop'; // Default to shop if not specified
|
||||
};
|
||||
|
||||
// Router wrapper component that uses hooks requiring Router context
|
||||
function AppRoutes() {
|
||||
const initialRoute = getInitialRoute();
|
||||
console.log('[WooNooW Customer] Using initial route:', initialRoute);
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Routes>
|
||||
{/* Root route redirects to initial route based on SPA mode */}
|
||||
<Route path="/" element={<Navigate to={initialRoute} replace />} />
|
||||
|
||||
{/* Shop Routes */}
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/product/:slug" element={<Product />} />
|
||||
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||
|
||||
{/* Wishlist - Public route accessible to guests */}
|
||||
<Route path="/wishlist" element={<Wishlist />} />
|
||||
|
||||
{/* Login & Auth */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* My Account */}
|
||||
<Route path="/my-account/*" element={<Account />} />
|
||||
|
||||
{/* Fallback to initial route */}
|
||||
<Route path="*" element={<Navigate to={initialRoute} replace />} />
|
||||
</Routes>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const themeConfig = getThemeConfig();
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<BaseLayout>
|
||||
<Routes>
|
||||
{/* Shop Routes */}
|
||||
<Route path="/" element={<Shop />} />
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/product/:slug" element={<Product />} />
|
||||
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||
|
||||
{/* My Account */}
|
||||
<Route path="/my-account/*" element={<Account />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/shop" replace />} />
|
||||
</Routes>
|
||||
</BaseLayout>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster position="top-right" richColors />
|
||||
{/* Toast notifications - position from settings */}
|
||||
<Toaster position={toastPosition} richColors />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
|
||||
interface NewsletterFormProps {
|
||||
description?: string;
|
||||
@@ -9,12 +8,6 @@ interface NewsletterFormProps {
|
||||
export function NewsletterForm({ description }: NewsletterFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
// Don't render if newsletter module is disabled
|
||||
if (!isEnabled('newsletter')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
139
customer-spa/src/components/ui/alert-dialog.tsx
Normal file
139
customer-spa/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
24
customer-spa/src/components/ui/input.tsx
Normal file
24
customer-spa/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
20
customer-spa/src/components/ui/label.tsx
Normal file
20
customer-spa/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
128
customer-spa/src/hooks/useAddToCartFromUrl.ts
Normal file
128
customer-spa/src/hooks/useAddToCartFromUrl.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
|
||||
/**
|
||||
* Hook to handle add-to-cart from URL parameters
|
||||
* Supports both simple and variable products
|
||||
*
|
||||
* URL formats:
|
||||
* - Simple product: ?add-to-cart=123
|
||||
* - Variable product: ?add-to-cart=123&variation_id=456
|
||||
* - With quantity: ?add-to-cart=123&quantity=2
|
||||
* - Direct to checkout: ?add-to-cart=123&redirect=checkout
|
||||
* - Stay on cart (default): ?add-to-cart=123&redirect=cart
|
||||
*/
|
||||
export function useAddToCartFromUrl() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setCart } = useCartStore();
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Check hash route for add-to-cart parameters
|
||||
const hash = window.location.hash;
|
||||
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
|
||||
const productId = hashParams.get('add-to-cart');
|
||||
|
||||
if (!productId) return;
|
||||
|
||||
const variationId = hashParams.get('variation_id');
|
||||
const quantity = parseInt(hashParams.get('quantity') || '1', 10);
|
||||
const redirect = hashParams.get('redirect') || 'cart';
|
||||
|
||||
// Create unique key for this add-to-cart request
|
||||
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
|
||||
|
||||
// Skip if already processed
|
||||
if (processedRef.current.has(requestKey)) {
|
||||
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Add to cart from URL:', {
|
||||
productId,
|
||||
variationId,
|
||||
quantity,
|
||||
redirect,
|
||||
fullUrl: window.location.href,
|
||||
requestKey,
|
||||
});
|
||||
|
||||
// Mark as processed
|
||||
processedRef.current.add(requestKey);
|
||||
|
||||
addToCart(productId, variationId, quantity)
|
||||
.then((cartData) => {
|
||||
// Update cart store with fresh data from API
|
||||
if (cartData) {
|
||||
setCart(cartData);
|
||||
console.log('[WooNooW] Cart updated with fresh data:', cartData);
|
||||
}
|
||||
|
||||
// Remove URL parameters after adding to cart
|
||||
const currentPath = window.location.hash.split('?')[0];
|
||||
window.location.hash = currentPath;
|
||||
|
||||
// Navigate based on redirect parameter
|
||||
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
|
||||
if (!location.pathname.includes(targetPage)) {
|
||||
console.log(`[WooNooW] Navigating to ${targetPage}`);
|
||||
navigate(targetPage);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[WooNooW] Failed to add product to cart:', error);
|
||||
toast.error('Failed to add product to cart');
|
||||
// Remove from processed set on error so it can be retried
|
||||
processedRef.current.delete(requestKey);
|
||||
});
|
||||
}, [location.hash, navigate, setCart]); // Include all dependencies
|
||||
}
|
||||
|
||||
async function addToCart(
|
||||
productId: string,
|
||||
variationId: string | null,
|
||||
quantity: number
|
||||
): Promise<any> {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
const body: any = {
|
||||
product_id: parseInt(productId, 10),
|
||||
quantity,
|
||||
};
|
||||
|
||||
if (variationId) {
|
||||
body.variation_id = parseInt(variationId, 10);
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Adding to cart:', body);
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to add to cart');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[WooNooW] Product added to cart:', data);
|
||||
|
||||
// API returns {message, cart_item_key, cart} on success
|
||||
if (data.cart_item_key && data.cart) {
|
||||
toast.success(data.message || 'Product added to cart');
|
||||
return data.cart; // Return cart data to update store
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to add to cart');
|
||||
}
|
||||
}
|
||||
25
customer-spa/src/hooks/useModuleSettings.ts
Normal file
25
customer-spa/src/hooks/useModuleSettings.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
|
||||
interface ModuleSettings {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch module settings
|
||||
*/
|
||||
export function useModuleSettings(moduleId: string) {
|
||||
const { data, isLoading } = useQuery<ModuleSettings>({
|
||||
queryKey: ['module-settings', moduleId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/modules/${moduleId}/settings`);
|
||||
return response || {};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
return {
|
||||
settings: data || {},
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export function useModules() {
|
||||
queryKey: ['modules-enabled'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules/enabled') as any;
|
||||
return response.data;
|
||||
// api.get returns the data directly, not wrapped in .data
|
||||
return response || { enabled: [] };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ interface WishlistItem {
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
||||
|
||||
export function useWishlist() {
|
||||
const [items, setItems] = useState<WishlistItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -26,10 +28,36 @@ export function useWishlist() {
|
||||
const isEnabled = settings?.wishlist_enabled !== false;
|
||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||
|
||||
// Load guest wishlist from localStorage
|
||||
const loadGuestWishlist = useCallback(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
||||
if (stored) {
|
||||
const guestIds = JSON.parse(stored) as number[];
|
||||
setProductIds(new Set(guestIds));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest wishlist:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save guest wishlist to localStorage
|
||||
const saveGuestWishlist = useCallback((ids: Set<number>) => {
|
||||
try {
|
||||
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
|
||||
} catch (error) {
|
||||
console.error('Failed to save guest wishlist:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load wishlist on mount
|
||||
useEffect(() => {
|
||||
if (isEnabled && isLoggedIn) {
|
||||
loadWishlist();
|
||||
if (isEnabled) {
|
||||
if (isLoggedIn) {
|
||||
loadWishlist();
|
||||
} else {
|
||||
loadGuestWishlist();
|
||||
}
|
||||
}
|
||||
}, [isEnabled, isLoggedIn]);
|
||||
|
||||
@@ -49,11 +77,17 @@ export function useWishlist() {
|
||||
}, [isLoggedIn]);
|
||||
|
||||
const addToWishlist = useCallback(async (productId: number) => {
|
||||
// Guest mode: store in localStorage only
|
||||
if (!isLoggedIn) {
|
||||
toast.error('Please login to add items to wishlist');
|
||||
return false;
|
||||
const newIds = new Set(productIds);
|
||||
newIds.add(productId);
|
||||
setProductIds(newIds);
|
||||
saveGuestWishlist(newIds);
|
||||
toast.success('Added to wishlist');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Logged in: use API
|
||||
try {
|
||||
await api.post('/account/wishlist', { product_id: productId });
|
||||
await loadWishlist(); // Reload to get full product details
|
||||
@@ -64,11 +98,20 @@ export function useWishlist() {
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
}, [isLoggedIn, loadWishlist]);
|
||||
}, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
|
||||
|
||||
const removeFromWishlist = useCallback(async (productId: number) => {
|
||||
if (!isLoggedIn) return false;
|
||||
// Guest mode: remove from localStorage only
|
||||
if (!isLoggedIn) {
|
||||
const newIds = new Set(productIds);
|
||||
newIds.delete(productId);
|
||||
setProductIds(newIds);
|
||||
saveGuestWishlist(newIds);
|
||||
toast.success('Removed from wishlist');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Logged in: use API
|
||||
try {
|
||||
await api.delete(`/account/wishlist/${productId}`);
|
||||
setItems(items.filter(item => item.product_id !== productId));
|
||||
@@ -83,7 +126,7 @@ export function useWishlist() {
|
||||
toast.error('Failed to remove from wishlist');
|
||||
return false;
|
||||
}
|
||||
}, [isLoggedIn, items]);
|
||||
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
|
||||
|
||||
const toggleWishlist = useCallback(async (productId: number) => {
|
||||
if (productIds.has(productId)) {
|
||||
@@ -103,6 +146,7 @@ export function useWishlist() {
|
||||
isEnabled,
|
||||
isLoggedIn,
|
||||
count: items.length,
|
||||
productIds,
|
||||
addToWishlist,
|
||||
removeFromWishlist,
|
||||
toggleWishlist,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSett
|
||||
import { SearchModal } from '../components/SearchModal';
|
||||
import { NewsletterForm } from '../components/NewsletterForm';
|
||||
import { LayoutWrapper } from './LayoutWrapper';
|
||||
import { useModules } from '../hooks/useModules';
|
||||
import { useModuleSettings } from '../hooks/useModuleSettings';
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -46,6 +48,8 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const headerSettings = useHeaderSettings();
|
||||
const { isEnabled } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
const footerSettings = useFooterSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
@@ -71,29 +75,29 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
{headerSettings.elements.logo && (
|
||||
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
||||
<Link to="/shop" className="flex items-center gap-3 group">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xl">W</span>
|
||||
</div>
|
||||
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
||||
{storeName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xl">W</span>
|
||||
</div>
|
||||
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
||||
{storeName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -117,42 +121,42 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<Search className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
{/* Account */}
|
||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||
<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="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>
|
||||
))}
|
||||
{/* Account */}
|
||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||
<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>
|
||||
) : (
|
||||
<Link to="/login" 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>
|
||||
))}
|
||||
|
||||
{/* Wishlist */}
|
||||
{headerSettings.elements.wishlist && (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false && user?.isLoggedIn && (
|
||||
<Link to="/my-account/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Wishlist</span>
|
||||
</Link>
|
||||
)}
|
||||
{/* Wishlist */}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
<Link to="/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Wishlist</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Cart */}
|
||||
{headerSettings.elements.cart && (
|
||||
<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" />
|
||||
{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">
|
||||
Cart ({itemCount})
|
||||
<div className="relative">
|
||||
<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">
|
||||
Cart ({itemCount})
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||
@@ -244,10 +248,10 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<span>Account</span>
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline">
|
||||
<Link to="/login" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Login</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -258,58 +262,67 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className={`grid ${footerGridClass} gap-8`}>
|
||||
{/* Render all sections dynamically */}
|
||||
{footerSettings.sections.filter((s: any) => s.visible).map((section: any) => (
|
||||
<div key={section.id}>
|
||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||
{footerSettings.sections
|
||||
.filter((s: any) => s.visible)
|
||||
.filter((s: any) => {
|
||||
// Filter out newsletter section if module is disabled
|
||||
if (s.type === 'newsletter' && !isEnabled('newsletter')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((section: any) => (
|
||||
<div key={section.id}>
|
||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||
|
||||
{/* Contact Section */}
|
||||
{section.type === 'contact' && (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
{footerSettings.contact_data.show_email && footerSettings.contact_data.email && (
|
||||
<p>Email: {footerSettings.contact_data.email}</p>
|
||||
)}
|
||||
{footerSettings.contact_data.show_phone && footerSettings.contact_data.phone && (
|
||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||
)}
|
||||
{footerSettings.contact_data.show_address && footerSettings.contact_data.address && (
|
||||
<p>{footerSettings.contact_data.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Contact Section */}
|
||||
{section.type === 'contact' && (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
{footerSettings.contact_data?.show_email && footerSettings.contact_data?.email && (
|
||||
<p>Email: {footerSettings.contact_data.email}</p>
|
||||
)}
|
||||
{footerSettings.contact_data?.show_phone && footerSettings.contact_data?.phone && (
|
||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||
)}
|
||||
{footerSettings.contact_data?.show_address && footerSettings.contact_data?.address && (
|
||||
<p>{footerSettings.contact_data.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Section */}
|
||||
{section.type === 'menu' && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/shop" className="text-gray-600 hover:text-primary no-underline">Shop</Link></li>
|
||||
<li><a href="/about" className="text-gray-600 hover:text-primary no-underline">About</a></li>
|
||||
<li><a href="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
||||
</ul>
|
||||
)}
|
||||
{/* Menu Section */}
|
||||
{section.type === 'menu' && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/shop" className="text-gray-600 hover:text-primary no-underline">Shop</Link></li>
|
||||
<li><a href="/about" className="text-gray-600 hover:text-primary no-underline">About</a></li>
|
||||
<li><a href="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Social Section */}
|
||||
{section.type === 'social' && footerSettings.social_links.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{footerSettings.social_links.map((link: any) => (
|
||||
<li key={link.id}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
||||
{link.platform}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Social Section */}
|
||||
{section.type === 'social' && footerSettings.social_links?.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{footerSettings.social_links.map((link: any) => (
|
||||
<li key={link.id}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
||||
{link.platform}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Newsletter Section */}
|
||||
{section.type === 'newsletter' && (
|
||||
<NewsletterForm description={footerSettings.labels.newsletter_description} />
|
||||
)}
|
||||
{/* Newsletter Section */}
|
||||
{section.type === 'newsletter' && (
|
||||
<NewsletterForm description={footerSettings.labels?.newsletter_description} />
|
||||
)}
|
||||
|
||||
{/* Custom HTML Section */}
|
||||
{section.type === 'custom' && (
|
||||
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Custom HTML Section */}
|
||||
{section.type === 'custom' && (
|
||||
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Payment Icons */}
|
||||
@@ -352,6 +365,8 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const headerSettings = useHeaderSettings();
|
||||
const { isEnabled } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -408,11 +423,16 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
<User className="h-4 w-4" /> Account
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Link to="/login" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<User className="h-4 w-4" /> Account
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
<Link to="/wishlist" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-4 w-4" /> Wishlist
|
||||
</Link>
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
@@ -480,6 +500,8 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'BOUTIQUE';
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const headerSettings = useHeaderSettings();
|
||||
const { isEnabled } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -497,23 +519,23 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
|
||||
{headerSettings.elements.logo && (
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/shop">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<Link to="/shop">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex justify-end">
|
||||
@@ -535,10 +557,15 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
<User className="h-4 w-4" /> Account
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Link to="/login" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<User className="h-4 w-4" /> Account
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
<Link to="/wishlist" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-4 w-4" /> Wishlist
|
||||
</Link>
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
@@ -602,8 +629,8 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
*/
|
||||
function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
const isCheckoutFlow = window.location.pathname.includes('/checkout') ||
|
||||
window.location.pathname.includes('/my-account') ||
|
||||
window.location.pathname.includes('/order-received');
|
||||
window.location.pathname.includes('/my-account') ||
|
||||
window.location.pathname.includes('/order-received');
|
||||
|
||||
if (!isCheckoutFlow) {
|
||||
// For non-checkout pages, use minimal layout
|
||||
|
||||
111
customer-spa/src/lib/cart/api.ts
Normal file
111
customer-spa/src/lib/cart/api.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Cart } from './store';
|
||||
|
||||
const getApiConfig = () => {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
return { apiRoot, nonce };
|
||||
};
|
||||
|
||||
/**
|
||||
* Update cart item quantity via API
|
||||
*/
|
||||
export async function updateCartItemQuantity(
|
||||
cartItemKey: string,
|
||||
quantity: number
|
||||
): Promise<Cart> {
|
||||
const { apiRoot, nonce } = getApiConfig();
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
cart_item_key: cartItemKey,
|
||||
quantity,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to update cart');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from cart via API
|
||||
*/
|
||||
export async function removeCartItem(cartItemKey: string): Promise<Cart> {
|
||||
const { apiRoot, nonce } = getApiConfig();
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/remove`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
cart_item_key: cartItemKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to remove item');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear entire cart via API
|
||||
*/
|
||||
export async function clearCartAPI(): Promise<Cart> {
|
||||
const { apiRoot, nonce } = getApiConfig();
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/clear`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to clear cart');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current cart from API
|
||||
*/
|
||||
export async function fetchCart(): Promise<Cart> {
|
||||
const { apiRoot, nonce } = getApiConfig();
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch cart');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { toast } from 'sonner';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
|
||||
interface WishlistItem {
|
||||
product_id: number;
|
||||
@@ -28,6 +29,7 @@ export default function Wishlist() {
|
||||
const [items, setItems] = useState<WishlistItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
|
||||
if (modulesLoading) {
|
||||
return (
|
||||
@@ -217,19 +219,21 @@ export default function Wishlist() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
onClick={() => handleAddToCart(item)}
|
||||
disabled={item.stock_status === 'outofstock'}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
{item.stock_status === 'outofstock'
|
||||
? 'Out of Stock'
|
||||
: item.type === 'variable'
|
||||
? 'Select Options'
|
||||
: 'Add to Cart'}
|
||||
</Button>
|
||||
{(wishlistSettings.show_add_to_cart_button ?? true) && (
|
||||
<Button
|
||||
onClick={() => handleAddToCart(item)}
|
||||
disabled={item.stock_status === 'outofstock'}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
{item.stock_status === 'outofstock'
|
||||
? 'Out of Stock'
|
||||
: item.type === 'variable'
|
||||
? 'Select Options'
|
||||
: 'Add to Cart'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface AccountLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -12,6 +23,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const { isEnabled } = useModules();
|
||||
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const allMenuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||
@@ -27,8 +39,27 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
window.location.href = '/wp-login.php?action=logout';
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
await fetch(`${apiRoot}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Full page reload to clear cookies and refresh state
|
||||
window.location.href = window.location.origin + '/store/';
|
||||
} catch (error) {
|
||||
// Even on error, try to redirect and let server handle session
|
||||
window.location.href = window.location.origin + '/store/';
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
@@ -38,6 +69,38 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
// Logout Button with AlertDialog
|
||||
const LogoutButton = () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
disabled={isLoggingOut}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium">{isLoggingOut ? 'Logging out...' : 'Logout'}</span>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Log out?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to log out of your account? You'll need to sign in again to access your orders and account details.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleLogout}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Log Out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
// Sidebar Navigation
|
||||
const SidebarNav = () => (
|
||||
<aside className="bg-white rounded-lg border p-4">
|
||||
@@ -60,11 +123,10 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
<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'
|
||||
}`}
|
||||
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>
|
||||
@@ -72,13 +134,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
<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>
|
||||
<LogoutButton />
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
@@ -93,11 +149,10 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
<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'
|
||||
}`}
|
||||
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>
|
||||
@@ -128,3 +183,4 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { AccountLayout } from './components/AccountLayout';
|
||||
import Dashboard from './Dashboard';
|
||||
@@ -12,11 +12,12 @@ import AccountDetails from './AccountDetails';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const location = useLocation();
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user?.isLoggedIn) {
|
||||
window.location.href = '/wp-login.php?redirect_to=' + encodeURIComponent(window.location.href);
|
||||
return null;
|
||||
const currentPath = location.pathname;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,37 +14,96 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Cart() {
|
||||
const navigate = useNavigate();
|
||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
||||
const { cart, setCart } = useCartStore();
|
||||
const { layout, elements } = useCartSettings();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fetch cart from server on mount to sync with WooCommerce
|
||||
useEffect(() => {
|
||||
const loadCart = async () => {
|
||||
try {
|
||||
const serverCart = await fetchCart();
|
||||
setCart(serverCart);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cart:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCart();
|
||||
}, [setCart]);
|
||||
|
||||
// Calculate total from items
|
||||
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
|
||||
const handleUpdateQuantity = (key: string, newQuantity: number) => {
|
||||
const handleUpdateQuantity = async (key: string, newQuantity: number) => {
|
||||
if (newQuantity < 1) {
|
||||
handleRemoveItem(key);
|
||||
return;
|
||||
}
|
||||
updateQuantity(key, newQuantity);
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updatedCart = await updateCartItemQuantity(key, newQuantity);
|
||||
setCart(updatedCart);
|
||||
} catch (error) {
|
||||
console.error('Failed to update quantity:', error);
|
||||
toast.error('Failed to update quantity');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (key: string) => {
|
||||
removeItem(key);
|
||||
toast.success('Item removed from cart');
|
||||
const handleRemoveItem = async (key: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updatedCart = await removeCartItem(key);
|
||||
setCart(updatedCart);
|
||||
toast.success('Item removed from cart');
|
||||
} catch (error) {
|
||||
console.error('Failed to remove item:', error);
|
||||
toast.error('Failed to remove item');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCart = () => {
|
||||
clearCart();
|
||||
setShowClearDialog(false);
|
||||
toast.success('Cart cleared');
|
||||
const handleClearCart = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updatedCart = await clearCartAPI();
|
||||
setCart(updatedCart);
|
||||
setShowClearDialog(false);
|
||||
toast.success('Cart cleared');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cart:', error);
|
||||
toast.error('Failed to clear cart');
|
||||
setShowClearDialog(false);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state while fetching cart
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center py-16">
|
||||
<Loader2 className="mx-auto h-16 w-16 text-gray-400 mb-4 animate-spin" />
|
||||
<p className="text-gray-600">Loading cart...</p>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (cart.items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@@ -237,13 +237,16 @@ export default function Checkout() {
|
||||
const data = (response as any).data || response;
|
||||
|
||||
if (data.ok && data.order_id) {
|
||||
// Clear cart
|
||||
cart.items.forEach(item => {
|
||||
useCartStore.getState().removeItem(item.key);
|
||||
});
|
||||
// Clear cart - use store method directly
|
||||
useCartStore.getState().clearCart();
|
||||
|
||||
toast.success('Order placed successfully!');
|
||||
navigate(`/order-received/${data.order_id}`);
|
||||
|
||||
// Use full page reload instead of SPA routing
|
||||
// This ensures auto-registered users get their auth cookies properly set
|
||||
const thankYouUrl = `${window.location.origin}/store/#/order-received/${data.order_id}?key=${data.order_key}`;
|
||||
window.location.href = thankYouUrl;
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to create order');
|
||||
}
|
||||
@@ -357,201 +360,16 @@ export default function Checkout() {
|
||||
|
||||
{/* 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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address fields - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ship to Different Address - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shipToDifferentAddress}
|
||||
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">Ship to a different address?</span>
|
||||
</label>
|
||||
|
||||
{shipToDifferentAddress && (
|
||||
<>
|
||||
{/* 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 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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
@@ -560,66 +378,251 @@ export default function Checkout() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
type="email"
|
||||
required
|
||||
value={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||
<input
|
||||
type="text"
|
||||
type="tel"
|
||||
required
|
||||
value={shippingData.city}
|
||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.postcode}
|
||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.country}
|
||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address fields - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ship to Different Address - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shipToDifferentAddress}
|
||||
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">Ship to a different address?</span>
|
||||
</label>
|
||||
|
||||
{shipToDifferentAddress && (
|
||||
<>
|
||||
{/* 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
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.city}
|
||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.postcode}
|
||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.country}
|
||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Notes */}
|
||||
|
||||
161
customer-spa/src/pages/ForgotPassword/index.tsx
Normal file
161
customer-spa/src/pages/ForgotPassword/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { KeyRound, ArrowLeft, Mail, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
const response = await fetch(`${apiRoot}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setIsSuccess(true);
|
||||
toast.success('Password reset email sent!');
|
||||
} else {
|
||||
setError(data.message || 'Failed to send reset email');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check Your Email</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We've sent a password reset link to <strong>{email}</strong>.
|
||||
Please check your inbox and click the link to reset your password.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link to="/login">
|
||||
<Button className="w-full">
|
||||
Return to Login
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSuccess(false);
|
||||
setEmail('');
|
||||
}}
|
||||
className="text-sm text-gray-600 hover:text-primary"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to login
|
||||
</Link>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<KeyRound className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Forgot Password?</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Enter your email and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Remember your password?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
202
customer-spa/src/pages/Login/index.tsx
Normal file
202
customer-spa/src/pages/Login/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { LogIn, Eye, EyeOff, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get('redirect') || '/my-account';
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
const response = await fetch(`${apiRoot}/auth/customer-login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update window config with new nonce and user data
|
||||
if ((window as any).woonoowCustomer) {
|
||||
(window as any).woonoowCustomer.nonce = data.nonce;
|
||||
(window as any).woonoowCustomer.user = {
|
||||
isLoggedIn: true,
|
||||
id: data.user.id,
|
||||
name: data.user.name,
|
||||
email: data.user.email,
|
||||
firstName: data.user.first_name,
|
||||
lastName: data.user.last_name,
|
||||
avatar: data.user.avatar,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge guest wishlist to account
|
||||
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
||||
try {
|
||||
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
||||
if (stored) {
|
||||
const guestProductIds = JSON.parse(stored) as number[];
|
||||
if (guestProductIds.length > 0) {
|
||||
// Merge each product to account wishlist
|
||||
const newNonce = data.nonce;
|
||||
for (const productId of guestProductIds) {
|
||||
try {
|
||||
await fetch(`${apiRoot}/account/wishlist`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': newNonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ product_id: productId }),
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip if product already in wishlist or other error
|
||||
console.debug('Wishlist merge skipped for product:', productId);
|
||||
}
|
||||
}
|
||||
// Clear guest wishlist after merge
|
||||
localStorage.removeItem(GUEST_WISHLIST_KEY);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to merge guest wishlist:', e);
|
||||
}
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Set the target URL with hash route, then force reload
|
||||
// The hash change alone doesn't reload the page, so cookies won't be refreshed
|
||||
const targetUrl = window.location.origin + '/store/#' + redirectTo;
|
||||
window.location.href = targetUrl;
|
||||
// Force page reload to refresh cookies and server-side state
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError(data.message || 'Login failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/shop"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Continue shopping
|
||||
</Link>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<LogIn className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Email or Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your email or username"
|
||||
required
|
||||
autoComplete="username"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer links */}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="hover:text-primary"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
300
customer-spa/src/pages/ResetPassword/index.tsx
Normal file
300
customer-spa/src/pages/ResetPassword/index.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { KeyRound, ArrowLeft, Eye, EyeOff, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const key = searchParams.get('key') || '';
|
||||
const login = searchParams.get('login') || '';
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
// Validate the reset key on mount
|
||||
useEffect(() => {
|
||||
const validateKey = async () => {
|
||||
if (!key || !login) {
|
||||
setError('Invalid password reset link. Please request a new one.');
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
const response = await fetch(`${apiRoot}/auth/validate-reset-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ key, login }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.valid) {
|
||||
setIsValid(true);
|
||||
} else {
|
||||
setError(data.message || 'This password reset link has expired or is invalid.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Unable to validate reset link. Please try again later.');
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateKey();
|
||||
}, [key, login]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
const response = await fetch(`${apiRoot}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ key, login, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setIsSuccess(true);
|
||||
toast.success('Password reset successfully!');
|
||||
} else {
|
||||
setError(data.message || 'Failed to reset password. Please try again.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred. Please try again later.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Password strength indicator
|
||||
const getPasswordStrength = (pwd: string) => {
|
||||
if (pwd.length === 0) return { label: '', color: '', width: '0%' };
|
||||
if (pwd.length < 8) return { label: 'Too short', color: 'bg-red-500', width: '25%' };
|
||||
|
||||
let strength = 0;
|
||||
if (pwd.length >= 8) strength++;
|
||||
if (pwd.length >= 12) strength++;
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
|
||||
if (/\d/.test(pwd)) strength++;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
|
||||
|
||||
if (strength <= 2) return { label: 'Weak', color: 'bg-orange-500', width: '50%' };
|
||||
if (strength <= 3) return { label: 'Medium', color: 'bg-yellow-500', width: '75%' };
|
||||
return { label: 'Strong', color: 'bg-green-500', width: '100%' };
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(password);
|
||||
|
||||
// Loading state
|
||||
if (isValidating) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-600">Validating reset link...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Password Reset!</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your password has been successfully updated. You can now log in with your new password.
|
||||
</p>
|
||||
<Link to="/login">
|
||||
<Button className="w-full">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state (invalid key)
|
||||
if (!isValid && error) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Invalid Reset Link</h1>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<Link to="/forgot-password">
|
||||
<Button className="w-full">Request New Link</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
return (
|
||||
<Container>
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to login
|
||||
</Link>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<KeyRound className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reset Your Password</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{password && (
|
||||
<div className="space-y-1">
|
||||
<div className="h-1 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${passwordStrength.color}`}
|
||||
style={{ width: passwordStrength.width }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Strength: <span className="font-medium">{passwordStrength.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="text-xs text-red-500">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || password !== confirmPassword}
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Remember your password?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { CheckCircle, ShoppingBag, Package, Truck } from 'lucide-react';
|
||||
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
export default function ThankYou() {
|
||||
const { orderId } = useParams<{ orderId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderKey = searchParams.get('key');
|
||||
const { template, headerVisibility, footerVisibility, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
||||
const [order, setOrder] = useState<any>(null);
|
||||
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrderData = async () => {
|
||||
if (!orderId) return;
|
||||
|
||||
try {
|
||||
const orderData = await apiClient.get(`/orders/${orderId}`) as any;
|
||||
// Use public order endpoint with key validation
|
||||
const keyParam = orderKey ? `?key=${orderKey}` : '';
|
||||
const orderData = await apiClient.get(`/checkout/order/${orderId}${keyParam}`) as any;
|
||||
setOrder(orderData);
|
||||
|
||||
// Fetch related products from first order item
|
||||
@@ -30,15 +36,16 @@ export default function ThankYou() {
|
||||
setRelatedProducts(productData.related_products.slice(0, 4));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch order data:', error);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch order data:', err);
|
||||
setError(err.message || 'Failed to load order');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrderData();
|
||||
}, [orderId]);
|
||||
}, [orderId, orderKey]);
|
||||
|
||||
if (loading || settingsLoading || !order) {
|
||||
return (
|
||||
@@ -68,55 +75,171 @@ export default function ThankYou() {
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-2xl mx-auto">
|
||||
{/* Receipt Container */}
|
||||
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
||||
{/* Receipt Header */}
|
||||
<div className="border-b-2 border-dashed border-gray-400 p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">PAYMENT RECEIPT</h1>
|
||||
<p className="text-gray-600">Order #{order.number}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
||||
<p className="text-sm text-center text-gray-700">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
{elements.order_details && (
|
||||
<div className="p-8">
|
||||
<div className="border-b-2 border-gray-900 pb-2 mb-4">
|
||||
<div className="flex justify-between text-sm font-bold">
|
||||
<span>ITEM</span>
|
||||
<span>AMOUNT</span>
|
||||
</div>
|
||||
<div className="py-12 max-w-2xl mx-auto">
|
||||
{/* Receipt Container */}
|
||||
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
||||
{/* Receipt Header */}
|
||||
<div className="border-b-2 border-dashed border-gray-400 p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">PAYMENT RECEIPT</h1>
|
||||
<p className="text-gray-600">Order #{order.number}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item: any) => (
|
||||
<div key={item.id}>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-sm text-gray-600">Qty: {item.qty}</div>
|
||||
</div>
|
||||
<div className="text-right font-mono">
|
||||
{formatPrice(item.total)}
|
||||
{/* Custom Message */}
|
||||
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
||||
<p className="text-sm text-center text-gray-700">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
{elements.order_details && (
|
||||
<div className="p-8">
|
||||
<div className="border-b-2 border-gray-900 pb-2 mb-4">
|
||||
<div className="flex justify-between text-sm font-bold">
|
||||
<span>ITEM</span>
|
||||
<span>AMOUNT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item: any) => (
|
||||
<div key={item.id}>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-sm text-gray-600">Qty: {item.qty}</div>
|
||||
</div>
|
||||
<div className="text-right font-mono">
|
||||
{formatPrice(item.total)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SUBTOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
</div>
|
||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SHIPPING:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.tax_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>TAX:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||
<span>TOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Status Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Method:</span>
|
||||
<span className="font-medium uppercase">{order.payment_method || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="font-medium uppercase">{getStatusLabel(order.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4">
|
||||
<div className="text-xs text-gray-600 uppercase mb-2">Bill To:</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{order.billing?.first_name} {order.billing?.last_name}
|
||||
</div>
|
||||
<div className="text-gray-600">{order.billing?.email}</div>
|
||||
{order.billing?.phone && (
|
||||
<div className="text-gray-600">{order.billing.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Footer */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{order.status === 'pending'
|
||||
? 'Awaiting payment confirmation'
|
||||
: 'Thank you for your business!'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{elements.continue_shopping_button && (
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<Link to="/my-account">
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
Go to Account
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/login">
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<LogIn className="w-5 h-5" />
|
||||
Login / Create Account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{relatedProducts.map((product: any) => (
|
||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||
) : (
|
||||
<Package className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||
{formatPrice(parseFloat(product.price || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -218,67 +341,115 @@ export default function ThankYou() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SUBTOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Order Confirmed!</h1>
|
||||
<p className="text-gray-600">Order #{order.number}</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<p className="text-gray-800 text-center">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Details */}
|
||||
{elements.order_details && (
|
||||
<div className="bg-white border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">Order Details</h2>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{order.items.map((item: any) => (
|
||||
<div key={item.id} className="flex items-center gap-4 pb-4 border-b last:border-0">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
{item.image && typeof item.image === 'string' ? (
|
||||
<img src={item.image} alt={item.name} className="w-full !h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<Package className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{item.name}</h3>
|
||||
<p className="text-sm text-gray-500">Quantity: {item.qty}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900">{formatPrice(item.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
</div>
|
||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SHIPPING:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Shipping</span>
|
||||
<span>{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.tax_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>TAX:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Tax</span>
|
||||
<span>{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||
<span>TOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Status Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Method:</span>
|
||||
<span className="font-medium uppercase">{order.payment_method || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="font-medium uppercase">{getStatusLabel(order.status)}</span>
|
||||
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4">
|
||||
<div className="text-xs text-gray-600 uppercase mb-2">Bill To:</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{order.billing?.first_name} {order.billing?.last_name}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Customer Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500 mb-1">Email</p>
|
||||
<p className="text-gray-900">{order.billing?.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 mb-1">Phone</p>
|
||||
<p className="text-gray-900">{order.billing?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Status */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Order Status: {getStatusLabel(order.status)}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{order.status === 'pending' ? 'Awaiting payment confirmation' : "We'll send you shipping updates via email"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-gray-600">{order.billing?.email}</div>
|
||||
{order.billing?.phone && (
|
||||
<div className="text-gray-600">{order.billing.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Footer */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{order.status === 'pending'
|
||||
? 'Awaiting payment confirmation'
|
||||
: 'Thank you for your business!'}
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="text-center flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{elements.continue_shopping_button && (
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
@@ -287,8 +458,22 @@ export default function ThankYou() {
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<Link to="/my-account">
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
Go to Account
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/login">
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<LogIn className="w-5 h-5" />
|
||||
Login / Create Account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
@@ -319,153 +504,7 @@ export default function ThankYou() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Order Confirmed!</h1>
|
||||
<p className="text-gray-600">Order #{order.number}</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<p className="text-gray-800 text-center">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Details */}
|
||||
{elements.order_details && (
|
||||
<div className="bg-white border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">Order Details</h2>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{order.items.map((item: any) => (
|
||||
<div key={item.id} className="flex items-center gap-4 pb-4 border-b last:border-0">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
{item.image && typeof item.image === 'string' ? (
|
||||
<img src={item.image} alt={item.name} className="w-full !h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<Package className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{item.name}</h3>
|
||||
<p className="text-sm text-gray-500">Quantity: {item.qty}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900">{formatPrice(item.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
</div>
|
||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Shipping</span>
|
||||
<span>{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.tax_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Tax</span>
|
||||
<span>{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Customer Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500 mb-1">Email</p>
|
||||
<p className="text-gray-900">{order.billing?.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 mb-1">Phone</p>
|
||||
<p className="text-gray-900">{order.billing?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Status */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Order Status: {getStatusLabel(order.status)}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{order.status === 'pending' ? 'Awaiting payment confirmation' : "We'll send you shipping updates via email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Shopping Button */}
|
||||
{elements.continue_shopping_button && (
|
||||
<div className="text-center">
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{relatedProducts.map((product: any) => (
|
||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||
) : (
|
||||
<Package className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||
{formatPrice(parseFloat(product.price || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
253
customer-spa/src/pages/Wishlist.tsx
Normal file
253
customer-spa/src/pages/Wishlist.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Trash2, ShoppingCart, Heart } from 'lucide-react';
|
||||
import { useWishlist } from '@/hooks/useWishlist';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
interface ProductData {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: string;
|
||||
regular_price?: string;
|
||||
sale_price?: string;
|
||||
image?: string;
|
||||
on_sale?: boolean;
|
||||
stock_status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public Wishlist Page - Accessible to both guests and logged-in users
|
||||
* Guests: Shows items from localStorage
|
||||
* Logged-in: Shows items from database via API
|
||||
*/
|
||||
export default function Wishlist() {
|
||||
const navigate = useNavigate();
|
||||
const { items, isLoading, isLoggedIn, removeFromWishlist, productIds } = useWishlist();
|
||||
const { addItem } = useCartStore();
|
||||
const [guestProducts, setGuestProducts] = useState<ProductData[]>([]);
|
||||
const [loadingGuest, setLoadingGuest] = useState(false);
|
||||
|
||||
// Fetch product details for guest wishlist
|
||||
useEffect(() => {
|
||||
const fetchGuestProducts = async () => {
|
||||
if (!isLoggedIn && productIds.size > 0) {
|
||||
setLoadingGuest(true);
|
||||
try {
|
||||
const ids = Array.from(productIds).join(',');
|
||||
const response = await apiClient.get<any>(`/shop/products?include=${ids}`);
|
||||
setGuestProducts(response.products || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch guest wishlist products:', error);
|
||||
} finally {
|
||||
setLoadingGuest(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchGuestProducts();
|
||||
}, [isLoggedIn, productIds]);
|
||||
|
||||
const handleRemove = async (productId: number) => {
|
||||
await removeFromWishlist(productId);
|
||||
// Remove from guest products list
|
||||
setGuestProducts(prev => prev.filter(p => p.id !== productId));
|
||||
};
|
||||
|
||||
const handleAddToCart = (product: ProductData) => {
|
||||
addItem({
|
||||
key: `product-${product.id}`,
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
price: parseFloat(product.sale_price || product.regular_price || product.price.replace(/[^0-9.]/g, '')),
|
||||
quantity: 1,
|
||||
image: product.image,
|
||||
});
|
||||
toast.success(`${product.name} added to cart`);
|
||||
};
|
||||
|
||||
if (isLoading || loadingGuest) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600">Loading wishlist...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Guest mode: have product details fetched from API
|
||||
const hasGuestItems = !isLoggedIn && guestProducts.length > 0;
|
||||
const hasLoggedInItems = isLoggedIn && items.length > 0;
|
||||
|
||||
if (!hasGuestItems && !hasLoggedInItems) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">My Wishlist</h1>
|
||||
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Start adding products you love to your wishlist
|
||||
</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
Browse Products
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
||||
<p className="text-gray-600">
|
||||
{isLoggedIn ? `${items.length} items` : `${guestProducts.length} items`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Guest Mode: Show full product details */}
|
||||
{!isLoggedIn && hasGuestItems && (
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Guest Wishlist:</strong> You have {guestProducts.length} items saved locally.
|
||||
<Link to="/login" className="underline ml-1">Login</Link> to sync your wishlist to your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guest Wishlist Items (with full product details) */}
|
||||
{!isLoggedIn && hasGuestItems && (
|
||||
<div className="space-y-4">
|
||||
{guestProducts.map((product) => (
|
||||
<div key={`guest-${product.id}`} className="bg-white border rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
||||
<Heart className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold">{product.name}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatPrice(parseFloat(product.sale_price || product.regular_price || product.price.replace(/[^0-9.]/g, '')))}
|
||||
</p>
|
||||
{product.stock_status === 'outofstock' && (
|
||||
<p className="text-sm text-red-600">Out of stock</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleAddToCart(product)}
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-1" />
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/product/${product.slug}`)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(product.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logged-in Wishlist Items (full details from API) */}
|
||||
{isLoggedIn && hasLoggedInItems && (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.product_id} className="bg-white border rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
||||
<Heart className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatPrice(parseFloat(item.price.replace(/[^0-9.]/g, '')))}
|
||||
</p>
|
||||
{item.stock_status === 'outofstock' && (
|
||||
<p className="text-sm text-red-600">Out of stock</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
addItem({
|
||||
key: `product-${item.product_id}`,
|
||||
product_id: item.product_id,
|
||||
name: item.name,
|
||||
price: parseFloat(item.price.replace(/[^0-9.]/g, '')),
|
||||
quantity: 1,
|
||||
image: item.image,
|
||||
});
|
||||
toast.success(`${item.name} added to cart`);
|
||||
}}
|
||||
disabled={item.stock_status === 'outofstock'}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-1" />
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/product/${item.slug}`)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(item.product_id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: { app: 'src/main.tsx' },
|
||||
output: { entryFileNames: 'app.js', assetFileNames: 'app.[ext]' }
|
||||
|
||||
175
examples/biteship-addon/README.md
Normal file
175
examples/biteship-addon/README.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Biteship Shipping Addon - Example
|
||||
|
||||
This is a **complete example** of a WooNooW addon that integrates with the module system.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### 1. Module Registration
|
||||
- Registers as a shipping module with icon, category, and features
|
||||
- Appears in Settings > Modules automatically
|
||||
- Shows gear icon for settings access
|
||||
|
||||
### 2. Two Settings Approaches
|
||||
|
||||
#### Option A: Schema-Based (No React Needed)
|
||||
Uncomment the schema registration in `biteship-addon.php` and set `settings_component` to `null`.
|
||||
|
||||
**Benefits**:
|
||||
- No build process required
|
||||
- Automatic form generation
|
||||
- Built-in validation
|
||||
- Perfect for simple settings
|
||||
|
||||
#### Option B: Custom React Component (Current)
|
||||
Uses `src/Settings.jsx` with WooNooW's exposed React API.
|
||||
|
||||
**Benefits**:
|
||||
- Full UI control
|
||||
- Custom validation logic
|
||||
- Advanced interactions (like "Test Connection" button)
|
||||
- Better for complex settings
|
||||
|
||||
### 3. Settings Persistence
|
||||
Both approaches use the same storage:
|
||||
- Stored in: `woonoow_module_biteship-shipping_settings`
|
||||
- Accessed via: `get_option('woonoow_module_biteship-shipping_settings')`
|
||||
- React hook: `useModuleSettings('biteship-shipping')`
|
||||
|
||||
### 4. Module Integration
|
||||
- Hooks into `woonoow/shipping/calculate_rates` filter
|
||||
- Checks if module is enabled before running
|
||||
- Reacts to settings changes via action hook
|
||||
|
||||
## Installation
|
||||
|
||||
### Development Mode (No Build)
|
||||
|
||||
1. Copy this folder to `wp-content/plugins/`
|
||||
2. Activate the plugin
|
||||
3. Go to Settings > Modules
|
||||
4. Enable "Biteship Shipping"
|
||||
5. Click gear icon to configure
|
||||
|
||||
### Production Mode (With Build)
|
||||
|
||||
```bash
|
||||
cd biteship-addon
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This compiles `src/Settings.jsx` to `dist/Settings.js`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
biteship-addon/
|
||||
├── biteship-addon.php # Main plugin file
|
||||
├── src/
|
||||
│ └── Settings.jsx # Custom React settings component
|
||||
├── dist/
|
||||
│ └── Settings.js # Compiled component (after build)
|
||||
├── package.json # Build configuration
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Using WooNooW API
|
||||
|
||||
The custom settings component uses `window.WooNooW` API:
|
||||
|
||||
```javascript
|
||||
const { React, hooks, components, icons, utils } = window.WooNooW;
|
||||
|
||||
// Hooks
|
||||
const { useModuleSettings } = hooks;
|
||||
const { settings, updateSettings } = useModuleSettings('biteship-shipping');
|
||||
|
||||
// Components
|
||||
const { SettingsLayout, SettingsCard, Button, Input } = components;
|
||||
|
||||
// Icons
|
||||
const { Save, Settings } = icons;
|
||||
|
||||
// Utils
|
||||
const { toast, api } = utils;
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "esbuild src/Settings.jsx --bundle --outfile=dist/Settings.js --format=iife --external:react --external:react-dom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Externalize React and React-DOM since WooNooW provides them!
|
||||
|
||||
## API Integration
|
||||
|
||||
The example shows placeholder shipping rates. In production:
|
||||
|
||||
1. Call Biteship API in `woonoow/shipping/calculate_rates` filter
|
||||
2. Use settings from `get_option('woonoow_module_biteship-shipping_settings')`
|
||||
3. Return formatted rates array
|
||||
|
||||
## Settings Schema Reference
|
||||
|
||||
```php
|
||||
'field_name' => [
|
||||
'type' => 'text|textarea|email|url|number|toggle|checkbox|select',
|
||||
'label' => 'Field Label',
|
||||
'description' => 'Help text',
|
||||
'placeholder' => 'Placeholder text',
|
||||
'required' => true|false,
|
||||
'default' => 'default value',
|
||||
'options' => ['key' => 'Label'], // For select fields
|
||||
'min' => 0, // For number fields
|
||||
'max' => 100, // For number fields
|
||||
]
|
||||
```
|
||||
|
||||
## Module Registration Reference
|
||||
|
||||
```php
|
||||
add_filter('woonoow/addon_registry', function($addons) {
|
||||
$addons['your-addon-id'] = [
|
||||
'id' => 'your-addon-id',
|
||||
'name' => 'Your Addon Name',
|
||||
'description' => 'Short description',
|
||||
'version' => '1.0.0',
|
||||
'author' => 'Your Name',
|
||||
'category' => 'shipping|payments|marketing|customers|products|analytics|other',
|
||||
'icon' => 'truck|credit-card|mail|users|package|bar-chart-3|puzzle',
|
||||
'features' => ['Feature 1', 'Feature 2'],
|
||||
'has_settings' => true,
|
||||
'settings_component' => plugin_dir_url(__FILE__) . 'dist/Settings.js', // Or null for schema
|
||||
];
|
||||
return $addons;
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. Enable the module in Settings > Modules
|
||||
2. Click gear icon
|
||||
3. Enter a test API key (format: `biteship_xxxxx`)
|
||||
4. Click "Test Connection" button
|
||||
5. Save settings
|
||||
6. Check that settings persist on page refresh
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Implement real Biteship API integration
|
||||
- Add courier selection UI
|
||||
- Add tracking number display
|
||||
- Add shipping label generation
|
||||
- Add webhook handling for status updates
|
||||
|
||||
## Support
|
||||
|
||||
For questions about WooNooW addon development:
|
||||
- Read: `ADDON_DEVELOPMENT_GUIDE.md`
|
||||
- Read: `ADDON_MODULE_DESIGN_DECISIONS.md`
|
||||
- Check: `types/woonoow-addon.d.ts` for TypeScript definitions
|
||||
177
examples/biteship-addon/biteship-addon.php
Normal file
177
examples/biteship-addon/biteship-addon.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: WooNooW Biteship Shipping
|
||||
* Plugin URI: https://woonoow.com/addons/biteship
|
||||
* Description: Indonesia shipping integration with Biteship API - Example WooNooW Addon
|
||||
* Version: 1.0.0
|
||||
* Author: WooNooW Team
|
||||
* Author URI: https://woonoow.com
|
||||
* Requires Plugins: woonoow
|
||||
*
|
||||
* This is an EXAMPLE addon demonstrating the WooNooW module system integration.
|
||||
* It shows both schema-based settings AND custom React component patterns.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* Register Biteship as a WooNooW Module
|
||||
*/
|
||||
add_filter('woonoow/addon_registry', function($addons) {
|
||||
$addons['biteship-shipping'] = [
|
||||
'id' => 'biteship-shipping',
|
||||
'name' => 'Biteship Shipping',
|
||||
'description' => 'Real-time shipping rates from Indonesian couriers (JNE, J&T, SiCepat, and more)',
|
||||
'version' => '1.0.0',
|
||||
'author' => 'WooNooW Team',
|
||||
'category' => 'shipping',
|
||||
'icon' => 'truck',
|
||||
'features' => [
|
||||
'Real-time shipping rates',
|
||||
'Multiple courier support (JNE, J&T, SiCepat, AnterAja, Ninja Express)',
|
||||
'Automatic tracking integration',
|
||||
'Shipping label generation',
|
||||
'Cash on Delivery (COD) support',
|
||||
],
|
||||
'has_settings' => true,
|
||||
// Option 1: Use schema-based settings (uncomment to use)
|
||||
// 'settings_component' => null,
|
||||
|
||||
// Option 2: Use custom React component (current)
|
||||
'settings_component' => plugin_dir_url(__FILE__) . 'dist/Settings.js',
|
||||
];
|
||||
return $addons;
|
||||
});
|
||||
|
||||
/**
|
||||
* Register Settings Schema (Option 1: Schema-based)
|
||||
*
|
||||
* This provides a no-code settings form automatically
|
||||
*/
|
||||
add_filter('woonoow/module_settings_schema', function($schemas) {
|
||||
$schemas['biteship-shipping'] = [
|
||||
'api_key' => [
|
||||
'type' => 'text',
|
||||
'label' => __('Biteship API Key', 'biteship'),
|
||||
'description' => __('Get your API key from Biteship dashboard', 'biteship'),
|
||||
'placeholder' => 'biteship_xxxxxxxxxxxxx',
|
||||
'required' => true,
|
||||
],
|
||||
'environment' => [
|
||||
'type' => 'select',
|
||||
'label' => __('Environment', 'biteship'),
|
||||
'description' => __('Use test mode for development', 'biteship'),
|
||||
'options' => [
|
||||
'test' => __('Test Mode', 'biteship'),
|
||||
'production' => __('Production', 'biteship'),
|
||||
],
|
||||
'default' => 'test',
|
||||
],
|
||||
'origin_lat' => [
|
||||
'type' => 'text',
|
||||
'label' => __('Origin Latitude', 'biteship'),
|
||||
'description' => __('Your warehouse latitude coordinate', 'biteship'),
|
||||
'placeholder' => '-6.200000',
|
||||
],
|
||||
'origin_lng' => [
|
||||
'type' => 'text',
|
||||
'label' => __('Origin Longitude', 'biteship'),
|
||||
'description' => __('Your warehouse longitude coordinate', 'biteship'),
|
||||
'placeholder' => '106.816666',
|
||||
],
|
||||
'enable_cod' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable Cash on Delivery', 'biteship'),
|
||||
'description' => __('Allow customers to pay on delivery', 'biteship'),
|
||||
'default' => false,
|
||||
],
|
||||
'enable_insurance' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable Shipping Insurance', 'biteship'),
|
||||
'description' => __('Automatically add insurance to shipments', 'biteship'),
|
||||
'default' => true,
|
||||
],
|
||||
'enabled_couriers' => [
|
||||
'type' => 'select',
|
||||
'label' => __('Enabled Couriers', 'biteship'),
|
||||
'description' => __('Select which couriers to show to customers', 'biteship'),
|
||||
'options' => [
|
||||
'jne' => 'JNE',
|
||||
'jnt' => 'J&T Express',
|
||||
'sicepat' => 'SiCepat',
|
||||
'anteraja' => 'AnterAja',
|
||||
'ninja' => 'Ninja Express',
|
||||
'idexpress' => 'ID Express',
|
||||
],
|
||||
],
|
||||
'debug_mode' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Debug Mode', 'biteship'),
|
||||
'description' => __('Log API requests for troubleshooting', 'biteship'),
|
||||
'default' => false,
|
||||
],
|
||||
];
|
||||
return $schemas;
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook into WooNooW shipping calculation
|
||||
*
|
||||
* This is where the actual shipping logic would go
|
||||
*/
|
||||
add_filter('woonoow/shipping/calculate_rates', function($rates, $package) {
|
||||
// Check if module is enabled
|
||||
if (!class_exists('WooNooW\Core\ModuleRegistry')) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
if (!\WooNooW\Core\ModuleRegistry::is_enabled('biteship-shipping')) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
// Get settings
|
||||
$settings = get_option('woonoow_module_biteship-shipping_settings', []);
|
||||
|
||||
if (empty($settings['api_key'])) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
// TODO: Call Biteship API to get real rates
|
||||
// For now, return example rates
|
||||
$rates[] = [
|
||||
'id' => 'biteship_jne_reg',
|
||||
'label' => 'JNE Regular',
|
||||
'cost' => 15000,
|
||||
'meta_data' => [
|
||||
'courier' => 'JNE',
|
||||
'service' => 'REG',
|
||||
'etd' => '2-3 days',
|
||||
],
|
||||
];
|
||||
|
||||
$rates[] = [
|
||||
'id' => 'biteship_jnt_reg',
|
||||
'label' => 'J&T Express Regular',
|
||||
'cost' => 12000,
|
||||
'meta_data' => [
|
||||
'courier' => 'J&T',
|
||||
'service' => 'REG',
|
||||
'etd' => '2-4 days',
|
||||
],
|
||||
];
|
||||
|
||||
return $rates;
|
||||
}, 10, 2);
|
||||
|
||||
/**
|
||||
* React to settings changes
|
||||
*/
|
||||
add_action('woonoow/module_settings_updated/biteship-shipping', function($settings) {
|
||||
// Clear any caches
|
||||
delete_transient('biteship_courier_list');
|
||||
|
||||
// Log settings update in debug mode
|
||||
if (!empty($settings['debug_mode'])) {
|
||||
error_log('Biteship settings updated: ' . print_r($settings, true));
|
||||
}
|
||||
});
|
||||
1
examples/biteship-addon/dist/Settings.js
vendored
Normal file
1
examples/biteship-addon/dist/Settings.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
(()=>{var{React:e,hooks:y,components:b,icons:_,utils:k}=window.WooNooW,{useModuleSettings:w}=y,{SettingsLayout:E,SettingsCard:c,Input:o,Button:f,Switch:r,Select:I,SelectContent:A,SelectItem:h,SelectTrigger:P,SelectValue:B,Badge:D}=b,{Settings:M,Save:L,AlertCircle:T,Check:W}=_,{toast:m}=k;function j(){let{settings:l,isLoading:v,updateSettings:i}=w("biteship-shipping"),[n,u]=e.useState({}),[d,g]=e.useState(!1),[s,p]=e.useState(null);e.useEffect(()=>{l&&u(l)},[l]);let a=(t,N)=>{u(S=>({...S,[t]:N}))},x=()=>{i.mutate(n)},C=async()=>{if(!n.api_key){m.error("Please enter an API key first");return}g(!0),p(null),setTimeout(()=>{let t=n.api_key.startsWith("biteship_");p(t?"success":"error"),g(!1),t?m.success("Connection successful!"):m.error("Invalid API key format")},1500)};return v?e.createElement(E,{title:"Biteship Settings",isLoading:!0}):e.createElement(E,{title:"Biteship Shipping Settings",description:"Configure your Biteship integration for Indonesian shipping"},e.createElement(c,{title:"API Configuration",description:"Connect your Biteship account"},e.createElement("div",{className:"space-y-4"},e.createElement("div",{className:"space-y-2"},e.createElement("label",{className:"text-sm font-medium"},"API Key"),e.createElement("div",{className:"flex gap-2"},e.createElement(o,{type:"password",value:n.api_key||"",onChange:t=>a("api_key",t.target.value),placeholder:"biteship_xxxxxxxxxxxxx"}),e.createElement(f,{variant:"outline",onClick:C,disabled:d},d?"Testing...":"Test Connection")),s&&e.createElement("div",{className:`flex items-center gap-2 text-sm ${s==="success"?"text-green-600":"text-red-600"}`},e.createElement(s==="success"?W:T,{className:"h-4 w-4"}),s==="success"?"Connection successful":"Connection failed")),e.createElement("div",{className:"space-y-2"},e.createElement("label",{className:"text-sm font-medium"},"Environment"),e.createElement(I,{value:n.environment||"test",onValueChange:t=>a("environment",t)},e.createElement(P,null,e.createElement(B,null)),e.createElement(A,null,e.createElement(h,{value:"test"},"Test Mode"),e.createElement(h,{value:"production"},"Production"))),e.createElement("p",{className:"text-xs text-muted-foreground"},"Use test mode for development and testing")))),e.createElement(c,{title:"Origin Location",description:"Your warehouse or pickup location"},e.createElement("div",{className:"grid grid-cols-2 gap-4"},e.createElement("div",{className:"space-y-2"},e.createElement("label",{className:"text-sm font-medium"},"Latitude"),e.createElement(o,{value:n.origin_lat||"",onChange:t=>a("origin_lat",t.target.value),placeholder:"-6.200000"})),e.createElement("div",{className:"space-y-2"},e.createElement("label",{className:"text-sm font-medium"},"Longitude"),e.createElement(o,{value:n.origin_lng||"",onChange:t=>a("origin_lng",t.target.value),placeholder:"106.816666"})))),e.createElement(c,{title:"Features",description:"Enable or disable shipping features"},e.createElement("div",{className:"space-y-4"},e.createElement("div",{className:"flex items-center justify-between"},e.createElement("div",null,e.createElement("p",{className:"font-medium"},"Cash on Delivery"),e.createElement("p",{className:"text-sm text-muted-foreground"},"Allow customers to pay on delivery")),e.createElement(r,{checked:n.enable_cod||!1,onCheckedChange:t=>a("enable_cod",t)})),e.createElement("div",{className:"flex items-center justify-between"},e.createElement("div",null,e.createElement("p",{className:"font-medium"},"Shipping Insurance"),e.createElement("p",{className:"text-sm text-muted-foreground"},"Automatically add insurance to shipments")),e.createElement(r,{checked:n.enable_insurance!==!1,onCheckedChange:t=>a("enable_insurance",t)})),e.createElement("div",{className:"flex items-center justify-between"},e.createElement("div",null,e.createElement("p",{className:"font-medium"},"Debug Mode"),e.createElement("p",{className:"text-sm text-muted-foreground"},"Log API requests for troubleshooting")),e.createElement(r,{checked:n.debug_mode||!1,onCheckedChange:t=>a("debug_mode",t)})))),e.createElement("div",{className:"flex justify-end"},e.createElement(f,{onClick:x,disabled:i.isPending},e.createElement(L,{className:"mr-2 h-4 w-4"}),i.isPending?"Saving...":"Save Settings")))}window.WooNooWAddon_biteship_shipping=j;})();
|
||||
446
examples/biteship-addon/package-lock.json
generated
Normal file
446
examples/biteship-addon/package-lock.json
generated
Normal file
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"name": "woonoow-biteship-addon",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "woonoow-biteship-addon",
|
||||
"version": "1.0.0",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
|
||||
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
|
||||
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
|
||||
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
|
||||
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
|
||||
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
|
||||
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
|
||||
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
|
||||
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
|
||||
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
|
||||
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
|
||||
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
|
||||
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
|
||||
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
|
||||
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
|
||||
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
|
||||
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
|
||||
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.19.12",
|
||||
"@esbuild/android-arm": "0.19.12",
|
||||
"@esbuild/android-arm64": "0.19.12",
|
||||
"@esbuild/android-x64": "0.19.12",
|
||||
"@esbuild/darwin-arm64": "0.19.12",
|
||||
"@esbuild/darwin-x64": "0.19.12",
|
||||
"@esbuild/freebsd-arm64": "0.19.12",
|
||||
"@esbuild/freebsd-x64": "0.19.12",
|
||||
"@esbuild/linux-arm": "0.19.12",
|
||||
"@esbuild/linux-arm64": "0.19.12",
|
||||
"@esbuild/linux-ia32": "0.19.12",
|
||||
"@esbuild/linux-loong64": "0.19.12",
|
||||
"@esbuild/linux-mips64el": "0.19.12",
|
||||
"@esbuild/linux-ppc64": "0.19.12",
|
||||
"@esbuild/linux-riscv64": "0.19.12",
|
||||
"@esbuild/linux-s390x": "0.19.12",
|
||||
"@esbuild/linux-x64": "0.19.12",
|
||||
"@esbuild/netbsd-x64": "0.19.12",
|
||||
"@esbuild/openbsd-x64": "0.19.12",
|
||||
"@esbuild/sunos-x64": "0.19.12",
|
||||
"@esbuild/win32-arm64": "0.19.12",
|
||||
"@esbuild/win32-ia32": "0.19.12",
|
||||
"@esbuild/win32-x64": "0.19.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
examples/biteship-addon/package.json
Normal file
15
examples/biteship-addon/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "woonoow-biteship-addon",
|
||||
"version": "1.0.0",
|
||||
"description": "Biteship shipping integration for WooNooW",
|
||||
"scripts": {
|
||||
"build": "esbuild src/Settings.jsx --bundle --outfile=dist/Settings.js --format=iife --external:react --external:react-dom --minify",
|
||||
"dev": "esbuild src/Settings.jsx --bundle --outfile=dist/Settings.js --format=iife --external:react --external:react-dom --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.19.0"
|
||||
},
|
||||
"keywords": ["woonoow", "addon", "shipping", "biteship", "indonesia"],
|
||||
"author": "WooNooW Team",
|
||||
"license": "GPL-2.0-or-later"
|
||||
}
|
||||
202
examples/biteship-addon/src/Settings.jsx
Normal file
202
examples/biteship-addon/src/Settings.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Biteship Custom Settings Component
|
||||
*
|
||||
* This demonstrates how to create a custom React settings page for a WooNooW addon
|
||||
* using the exposed window.WooNooW API
|
||||
*/
|
||||
|
||||
// Access WooNooW API from window
|
||||
const { React, hooks, components, icons, utils } = window.WooNooW;
|
||||
const { useModuleSettings } = hooks;
|
||||
const { SettingsLayout, SettingsCard, Input, Button, Switch, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Badge } = components;
|
||||
const { Settings: SettingsIcon, Save, AlertCircle, Check } = icons;
|
||||
const { toast } = utils;
|
||||
|
||||
function BiteshipSettings() {
|
||||
const { settings, isLoading, updateSettings } = useModuleSettings('biteship-shipping');
|
||||
const [formData, setFormData] = React.useState({});
|
||||
const [testingConnection, setTestingConnection] = React.useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = React.useState(null);
|
||||
|
||||
// Initialize form data from settings
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData(settings);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate(formData);
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!formData.api_key) {
|
||||
toast.error('Please enter an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingConnection(true);
|
||||
setConnectionStatus(null);
|
||||
|
||||
// Simulate API test (in real addon, call Biteship API)
|
||||
setTimeout(() => {
|
||||
const isValid = formData.api_key.startsWith('biteship_');
|
||||
setConnectionStatus(isValid ? 'success' : 'error');
|
||||
setTestingConnection(false);
|
||||
|
||||
if (isValid) {
|
||||
toast.success('Connection successful!');
|
||||
} else {
|
||||
toast.error('Invalid API key format');
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return React.createElement(SettingsLayout, { title: 'Biteship Settings', isLoading: true });
|
||||
}
|
||||
|
||||
return React.createElement(SettingsLayout, {
|
||||
title: 'Biteship Shipping Settings',
|
||||
description: 'Configure your Biteship integration for Indonesian shipping'
|
||||
},
|
||||
// API Configuration Card
|
||||
React.createElement(SettingsCard, {
|
||||
title: 'API Configuration',
|
||||
description: 'Connect your Biteship account'
|
||||
},
|
||||
React.createElement('div', { className: 'space-y-4' },
|
||||
// API Key
|
||||
React.createElement('div', { className: 'space-y-2' },
|
||||
React.createElement('label', { className: 'text-sm font-medium' }, 'API Key'),
|
||||
React.createElement('div', { className: 'flex gap-2' },
|
||||
React.createElement(Input, {
|
||||
type: 'password',
|
||||
value: formData.api_key || '',
|
||||
onChange: (e) => handleChange('api_key', e.target.value),
|
||||
placeholder: 'biteship_xxxxxxxxxxxxx'
|
||||
}),
|
||||
React.createElement(Button, {
|
||||
variant: 'outline',
|
||||
onClick: testConnection,
|
||||
disabled: testingConnection
|
||||
}, testingConnection ? 'Testing...' : 'Test Connection')
|
||||
),
|
||||
connectionStatus && React.createElement('div', {
|
||||
className: `flex items-center gap-2 text-sm ${connectionStatus === 'success' ? 'text-green-600' : 'text-red-600'}`
|
||||
},
|
||||
React.createElement(connectionStatus === 'success' ? Check : AlertCircle, { className: 'h-4 w-4' }),
|
||||
connectionStatus === 'success' ? 'Connection successful' : 'Connection failed'
|
||||
)
|
||||
),
|
||||
|
||||
// Environment
|
||||
React.createElement('div', { className: 'space-y-2' },
|
||||
React.createElement('label', { className: 'text-sm font-medium' }, 'Environment'),
|
||||
React.createElement(Select, {
|
||||
value: formData.environment || 'test',
|
||||
onValueChange: (value) => handleChange('environment', value)
|
||||
},
|
||||
React.createElement(SelectTrigger, null,
|
||||
React.createElement(SelectValue, null)
|
||||
),
|
||||
React.createElement(SelectContent, null,
|
||||
React.createElement(SelectItem, { value: 'test' }, 'Test Mode'),
|
||||
React.createElement(SelectItem, { value: 'production' }, 'Production')
|
||||
)
|
||||
),
|
||||
React.createElement('p', { className: 'text-xs text-muted-foreground' },
|
||||
'Use test mode for development and testing'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Origin Location Card
|
||||
React.createElement(SettingsCard, {
|
||||
title: 'Origin Location',
|
||||
description: 'Your warehouse or pickup location'
|
||||
},
|
||||
React.createElement('div', { className: 'grid grid-cols-2 gap-4' },
|
||||
React.createElement('div', { className: 'space-y-2' },
|
||||
React.createElement('label', { className: 'text-sm font-medium' }, 'Latitude'),
|
||||
React.createElement(Input, {
|
||||
value: formData.origin_lat || '',
|
||||
onChange: (e) => handleChange('origin_lat', e.target.value),
|
||||
placeholder: '-6.200000'
|
||||
})
|
||||
),
|
||||
React.createElement('div', { className: 'space-y-2' },
|
||||
React.createElement('label', { className: 'text-sm font-medium' }, 'Longitude'),
|
||||
React.createElement(Input, {
|
||||
value: formData.origin_lng || '',
|
||||
onChange: (e) => handleChange('origin_lng', e.target.value),
|
||||
placeholder: '106.816666'
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Features Card
|
||||
React.createElement(SettingsCard, {
|
||||
title: 'Features',
|
||||
description: 'Enable or disable shipping features'
|
||||
},
|
||||
React.createElement('div', { className: 'space-y-4' },
|
||||
// COD
|
||||
React.createElement('div', { className: 'flex items-center justify-between' },
|
||||
React.createElement('div', null,
|
||||
React.createElement('p', { className: 'font-medium' }, 'Cash on Delivery'),
|
||||
React.createElement('p', { className: 'text-sm text-muted-foreground' }, 'Allow customers to pay on delivery')
|
||||
),
|
||||
React.createElement(Switch, {
|
||||
checked: formData.enable_cod || false,
|
||||
onCheckedChange: (checked) => handleChange('enable_cod', checked)
|
||||
})
|
||||
),
|
||||
|
||||
// Insurance
|
||||
React.createElement('div', { className: 'flex items-center justify-between' },
|
||||
React.createElement('div', null,
|
||||
React.createElement('p', { className: 'font-medium' }, 'Shipping Insurance'),
|
||||
React.createElement('p', { className: 'text-sm text-muted-foreground' }, 'Automatically add insurance to shipments')
|
||||
),
|
||||
React.createElement(Switch, {
|
||||
checked: formData.enable_insurance !== false,
|
||||
onCheckedChange: (checked) => handleChange('enable_insurance', checked)
|
||||
})
|
||||
),
|
||||
|
||||
// Debug Mode
|
||||
React.createElement('div', { className: 'flex items-center justify-between' },
|
||||
React.createElement('div', null,
|
||||
React.createElement('p', { className: 'font-medium' }, 'Debug Mode'),
|
||||
React.createElement('p', { className: 'text-sm text-muted-foreground' }, 'Log API requests for troubleshooting')
|
||||
),
|
||||
React.createElement(Switch, {
|
||||
checked: formData.debug_mode || false,
|
||||
onCheckedChange: (checked) => handleChange('debug_mode', checked)
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Save Button
|
||||
React.createElement('div', { className: 'flex justify-end' },
|
||||
React.createElement(Button, {
|
||||
onClick: handleSave,
|
||||
disabled: updateSettings.isPending
|
||||
},
|
||||
React.createElement(Save, { className: 'mr-2 h-4 w-4' }),
|
||||
updateSettings.isPending ? 'Saving...' : 'Save Settings'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Export component to global scope for WooNooW to load
|
||||
window.WooNooWAddon_biteship_shipping = BiteshipSettings;
|
||||
@@ -56,6 +56,13 @@ class AppearanceController {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get all WordPress pages for page selector
|
||||
register_rest_route(self::API_NAMESPACE, '/pages/list', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_pages_list'],
|
||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function check_permission() {
|
||||
@@ -82,6 +89,8 @@ class AppearanceController {
|
||||
|
||||
$general_data = [
|
||||
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
||||
'spa_page' => absint($request->get_param('spaPage') ?? 0),
|
||||
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
|
||||
'typography' => [
|
||||
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
||||
'predefined_pair' => sanitize_text_field($request->get_param('typography')['predefined_pair'] ?? 'modern'),
|
||||
@@ -370,6 +379,30 @@ class AppearanceController {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of WordPress pages for page selector
|
||||
*/
|
||||
public static function get_pages_list(WP_REST_Request $request) {
|
||||
$pages = get_pages([
|
||||
'post_status' => 'publish',
|
||||
'sort_column' => 'post_title',
|
||||
'sort_order' => 'ASC',
|
||||
]);
|
||||
|
||||
$pages_list = array_map(function($page) {
|
||||
return [
|
||||
'id' => $page->ID,
|
||||
'title' => $page->post_title,
|
||||
'slug' => $page->post_name,
|
||||
];
|
||||
}, $pages);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $pages_list,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default settings structure
|
||||
*/
|
||||
@@ -377,6 +410,8 @@ class AppearanceController {
|
||||
return [
|
||||
'general' => [
|
||||
'spa_mode' => 'full',
|
||||
'spa_page' => 0,
|
||||
'toast_position' => 'top-right',
|
||||
'typography' => [
|
||||
'mode' => 'predefined',
|
||||
'predefined_pair' => 'modern',
|
||||
|
||||
@@ -6,12 +6,15 @@ use WooNooW\Compat\AddonRegistry;
|
||||
use WooNooW\Compat\RouteRegistry;
|
||||
use WooNooW\Compat\NavigationRegistry;
|
||||
|
||||
class Assets {
|
||||
public static function init() {
|
||||
class Assets
|
||||
{
|
||||
public static function init()
|
||||
{
|
||||
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
|
||||
}
|
||||
|
||||
public static function enqueue($hook) {
|
||||
public static function enqueue($hook)
|
||||
{
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
||||
@@ -42,7 +45,8 @@ class Assets {
|
||||
/** ----------------------------------------
|
||||
* DEV MODE (Vite dev server)
|
||||
* -------------------------------------- */
|
||||
private static function enqueue_dev(): void {
|
||||
private static function enqueue_dev(): void
|
||||
{
|
||||
$dev_url = self::dev_server_url(); // e.g. http://localhost:5173
|
||||
|
||||
// 1) Create a small handle to attach config (window.WNW_API)
|
||||
@@ -53,38 +57,38 @@ class Assets {
|
||||
// Attach runtime config (before module loader runs)
|
||||
// If you prefer, keep using self::localize_runtime($handle)
|
||||
wp_localize_script($handle, 'WNW_API', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => true,
|
||||
'devServer' => $dev_url,
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => true,
|
||||
'devServer' => $dev_url,
|
||||
'adminScreen' => 'woonoow',
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.WNW_API = window.WNW_API || WNW_API;', 'after');
|
||||
|
||||
// WNW_CONFIG for compatibility with standalone mode code
|
||||
wp_localize_script($handle, 'WNW_CONFIG', [
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'isAuthenticated' => is_user_logged_in(),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
wp_localize_script($handle, 'wpApiSettings', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.wpApiSettings = window.wpApiSettings || wpApiSettings;', 'after');
|
||||
|
||||
// Also expose compact global for convenience
|
||||
wp_localize_script($handle, 'wnw', [
|
||||
'isDev' => true,
|
||||
'isDev' => true,
|
||||
'devServer' => $dev_url,
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
||||
@@ -117,11 +121,11 @@ class Assets {
|
||||
// 1) React Refresh preamble (required by @vitejs/plugin-react)
|
||||
?>
|
||||
<script type="module">
|
||||
import RefreshRuntime from "<?php echo esc_url( $dev_url ); ?>/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
import RefreshRuntime from "<?php echo esc_url($dev_url); ?>/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => { };
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<?php
|
||||
|
||||
@@ -136,17 +140,18 @@ class Assets {
|
||||
/** ----------------------------------------
|
||||
* PROD MODE (built assets in admin-spa/dist)
|
||||
* -------------------------------------- */
|
||||
private static function enqueue_prod(): void {
|
||||
private static function enqueue_prod(): void
|
||||
{
|
||||
// Get plugin root directory (2 levels up from includes/Admin/)
|
||||
$plugin_dir = dirname(dirname(__DIR__));
|
||||
$dist_dir = $plugin_dir . '/admin-spa/dist/';
|
||||
$base_url = plugins_url('admin-spa/dist/', $plugin_dir . '/woonoow.php');
|
||||
|
||||
$css = 'app.css';
|
||||
$js = 'app.js';
|
||||
$js = 'app.js';
|
||||
|
||||
$ver_css = file_exists($dist_dir . $css) ? (string) filemtime($dist_dir . $css) : self::asset_version();
|
||||
$ver_js = file_exists($dist_dir . $js) ? (string) filemtime($dist_dir . $js) : self::asset_version();
|
||||
$ver_js = file_exists($dist_dir . $js) ? (string) filemtime($dist_dir . $js) : self::asset_version();
|
||||
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
@@ -159,51 +164,49 @@ class Assets {
|
||||
|
||||
if (file_exists($dist_dir . $css)) {
|
||||
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
|
||||
|
||||
// Fix icon rendering in WP-Admin (prevent WordPress admin styles from overriding)
|
||||
$icon_fix_css = '
|
||||
/* Fix Lucide icons in WP-Admin - force outlined style */
|
||||
#woonoow-admin-app svg {
|
||||
fill: none !important;
|
||||
stroke: currentColor !important;
|
||||
stroke-width: 2 !important;
|
||||
stroke-linecap: round !important;
|
||||
stroke-linejoin: round !important;
|
||||
}
|
||||
';
|
||||
wp_add_inline_style('wnw-admin', $icon_fix_css);
|
||||
// Note: Icon fixes are now in index.css with proper specificity
|
||||
}
|
||||
|
||||
if (file_exists($dist_dir . $js)) {
|
||||
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
|
||||
|
||||
// Add type="module" attribute for Vite build
|
||||
add_filter('script_loader_tag', function ($tag, $handle, $src) {
|
||||
if ($handle === 'wnw-admin') {
|
||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||
}
|
||||
return $tag;
|
||||
}, 10, 3);
|
||||
|
||||
self::localize_runtime('wnw-admin');
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach runtime config to a handle */
|
||||
private static function localize_runtime(string $handle): void {
|
||||
private static function localize_runtime(string $handle): void
|
||||
{
|
||||
wp_localize_script($handle, 'WNW_API', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => self::is_dev_mode(),
|
||||
'devServer' => self::dev_server_url(),
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => self::is_dev_mode(),
|
||||
'devServer' => self::dev_server_url(),
|
||||
'adminScreen' => 'woonoow',
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
]);
|
||||
|
||||
// WNW_CONFIG for compatibility with standalone mode code
|
||||
wp_localize_script($handle, 'WNW_CONFIG', [
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'isAuthenticated' => is_user_logged_in(),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
]);
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
wp_localize_script($handle, 'wpApiSettings', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
]);
|
||||
|
||||
@@ -212,9 +215,9 @@ class Assets {
|
||||
|
||||
// Compact global (prod)
|
||||
wp_localize_script($handle, 'wnw', [
|
||||
'isDev' => (bool) self::is_dev_mode(),
|
||||
'isDev' => (bool) self::is_dev_mode(),
|
||||
'devServer' => (string) self::dev_server_url(),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
||||
@@ -240,22 +243,23 @@ class Assets {
|
||||
}
|
||||
|
||||
/** Runtime store meta for frontend (currency, decimals, separators, position). */
|
||||
private static function store_runtime(): array {
|
||||
private static function store_runtime(): array
|
||||
{
|
||||
// WooCommerce helpers may not exist in some contexts; guard with defaults
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
|
||||
$decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
|
||||
$thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
|
||||
$decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
|
||||
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
|
||||
$decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
|
||||
$thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
|
||||
$decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
|
||||
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
|
||||
|
||||
return [
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $currency_sym,
|
||||
'decimals' => (int) $decimals,
|
||||
'thousand_sep' => (string) $thousand_sep,
|
||||
'decimal_sep' => (string) $decimal_sep,
|
||||
'currency_pos' => (string) $currency_pos,
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $currency_sym,
|
||||
'decimals' => (int) $decimals,
|
||||
'thousand_sep' => (string) $thousand_sep,
|
||||
'decimal_sep' => (string) $decimal_sep,
|
||||
'currency_pos' => (string) $currency_pos,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -266,9 +270,10 @@ class Assets {
|
||||
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode
|
||||
* in Local by Flywheel or other local dev environments.
|
||||
*/
|
||||
private static function is_dev_mode(): bool {
|
||||
private static function is_dev_mode(): bool
|
||||
{
|
||||
// Only enable dev mode if explicitly set via constant
|
||||
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
|
||||
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
|
||||
|
||||
/**
|
||||
* Filter: force dev/prod mode for WooNooW admin assets.
|
||||
@@ -288,7 +293,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** Dev server URL (filterable) */
|
||||
private static function dev_server_url(): string {
|
||||
private static function dev_server_url(): string
|
||||
{
|
||||
// Auto-detect based on current host (for Local by Flywheel compatibility)
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$protocol = is_ssl() ? 'https' : 'http';
|
||||
@@ -305,7 +311,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** Basic asset versioning */
|
||||
private static function asset_version(): string {
|
||||
private static function asset_version(): string
|
||||
{
|
||||
// Bump when releasing; in dev we don't cache-bust
|
||||
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
|
||||
}
|
||||
|
||||
@@ -78,6 +78,58 @@ class AuthController {
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer login endpoint (no admin permission required)
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response Response object
|
||||
*/
|
||||
public static function customer_login( WP_REST_Request $request ): WP_REST_Response {
|
||||
$username = sanitize_text_field( $request->get_param( 'username' ) );
|
||||
$password = $request->get_param( 'password' );
|
||||
|
||||
if ( empty( $username ) || empty( $password ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'Username and password are required', 'woonoow' ),
|
||||
], 400 );
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
$user = wp_authenticate( $username, $password );
|
||||
|
||||
if ( is_wp_error( $user ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'Invalid username or password', 'woonoow' ),
|
||||
], 401 );
|
||||
}
|
||||
|
||||
// Clear old cookies and set new ones
|
||||
wp_clear_auth_cookie();
|
||||
wp_set_current_user( $user->ID );
|
||||
wp_set_auth_cookie( $user->ID, true );
|
||||
|
||||
// Trigger login action
|
||||
do_action( 'wp_login', $user->user_login, $user );
|
||||
|
||||
// Get customer data
|
||||
$customer_data = [
|
||||
'id' => $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
'first_name' => get_user_meta( $user->ID, 'first_name', true ),
|
||||
'last_name' => get_user_meta( $user->ID, 'last_name', true ),
|
||||
'avatar' => get_avatar_url( $user->ID ),
|
||||
];
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'user' => $customer_data,
|
||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout endpoint
|
||||
*
|
||||
@@ -134,4 +186,144 @@ class AuthController {
|
||||
],
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password endpoint - sends password reset email
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response Response object
|
||||
*/
|
||||
public static function forgot_password( WP_REST_Request $request ): WP_REST_Response {
|
||||
$email = sanitize_email( $request->get_param( 'email' ) );
|
||||
|
||||
if ( empty( $email ) || ! is_email( $email ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'Please enter a valid email address', 'woonoow' ),
|
||||
], 400 );
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
$user = get_user_by( 'email', $email );
|
||||
|
||||
if ( ! $user ) {
|
||||
// For security, don't reveal if email exists or not
|
||||
// But still return success to prevent email enumeration attacks
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'message' => __( 'If an account exists with this email, you will receive a password reset link.', 'woonoow' ),
|
||||
], 200 );
|
||||
}
|
||||
|
||||
// Use WordPress's built-in password reset functionality
|
||||
$result = retrieve_password( $user->user_login );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'Failed to send password reset email. Please try again.', 'woonoow' ),
|
||||
], 500 );
|
||||
}
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'message' => __( 'Password reset email sent! Please check your inbox.', 'woonoow' ),
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password reset key
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response Response object
|
||||
*/
|
||||
public static function validate_reset_key( WP_REST_Request $request ): WP_REST_Response {
|
||||
$key = sanitize_text_field( $request->get_param( 'key' ) );
|
||||
$login = sanitize_text_field( $request->get_param( 'login' ) );
|
||||
|
||||
if ( empty( $key ) || empty( $login ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'valid' => false,
|
||||
'message' => __( 'Invalid password reset link', 'woonoow' ),
|
||||
], 400 );
|
||||
}
|
||||
|
||||
// Check the reset key
|
||||
$user = check_password_reset_key( $key, $login );
|
||||
|
||||
if ( is_wp_error( $user ) ) {
|
||||
$error_code = $user->get_error_code();
|
||||
$message = __( 'This password reset link has expired or is invalid.', 'woonoow' );
|
||||
|
||||
if ( $error_code === 'invalid_key' ) {
|
||||
$message = __( 'This password reset link is invalid.', 'woonoow' );
|
||||
} elseif ( $error_code === 'expired_key' ) {
|
||||
$message = __( 'This password reset link has expired. Please request a new one.', 'woonoow' );
|
||||
}
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'valid' => false,
|
||||
'message' => $message,
|
||||
], 400 );
|
||||
}
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'valid' => true,
|
||||
'user' => [
|
||||
'login' => $user->user_login,
|
||||
'email' => $user->user_email,
|
||||
],
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with key
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response Response object
|
||||
*/
|
||||
public static function reset_password( WP_REST_Request $request ): WP_REST_Response {
|
||||
$key = sanitize_text_field( $request->get_param( 'key' ) );
|
||||
$login = sanitize_text_field( $request->get_param( 'login' ) );
|
||||
$password = $request->get_param( 'password' );
|
||||
|
||||
if ( empty( $key ) || empty( $login ) || empty( $password ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'Missing required fields', 'woonoow' ),
|
||||
], 400 );
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if ( strlen( $password ) < 8 ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'Password must be at least 8 characters long', 'woonoow' ),
|
||||
], 400 );
|
||||
}
|
||||
|
||||
// Validate the reset key
|
||||
$user = check_password_reset_key( $key, $login );
|
||||
|
||||
if ( is_wp_error( $user ) ) {
|
||||
return new WP_REST_Response( [
|
||||
'success' => false,
|
||||
'message' => __( 'This password reset link has expired or is invalid. Please request a new one.', 'woonoow' ),
|
||||
], 400 );
|
||||
}
|
||||
|
||||
// Reset the password
|
||||
reset_password( $user, $password );
|
||||
|
||||
// Delete the password reset key so it can't be reused
|
||||
delete_user_meta( $user->ID, 'default_password_nag' );
|
||||
|
||||
// Trigger password changed action
|
||||
do_action( 'password_reset', $user, $password );
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'message' => __( 'Password reset successfully. You can now log in with your new password.', 'woonoow' ),
|
||||
], 200 );
|
||||
}
|
||||
}
|
||||
|
||||
320
includes/Api/CampaignsController.php
Normal file
320
includes/Api/CampaignsController.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
/**
|
||||
* Campaigns REST Controller
|
||||
*
|
||||
* REST API endpoints for newsletter campaigns
|
||||
*
|
||||
* @package WooNooW\API
|
||||
*/
|
||||
|
||||
namespace WooNooW\API;
|
||||
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Core\Campaigns\CampaignManager;
|
||||
|
||||
class CampaignsController {
|
||||
|
||||
const API_NAMESPACE = 'woonoow/v1';
|
||||
|
||||
/**
|
||||
* Register REST routes
|
||||
*/
|
||||
public static function register_routes() {
|
||||
// List campaigns
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_campaigns'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Create campaign
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_campaign'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Get single campaign
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_campaign'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Update campaign
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||
'methods' => 'PUT',
|
||||
'callback' => [__CLASS__, 'update_campaign'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Delete campaign
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_campaign'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Send campaign
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/send', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'send_campaign'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Send test email
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/test', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'send_test_email'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Preview campaign
|
||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/preview', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'preview_campaign'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check admin permission
|
||||
*/
|
||||
public static function check_admin_permission() {
|
||||
return current_user_can('manage_options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all campaigns
|
||||
*/
|
||||
public static function get_campaigns(WP_REST_Request $request) {
|
||||
$campaigns = CampaignManager::get_all();
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $campaigns,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create campaign
|
||||
*/
|
||||
public static function create_campaign(WP_REST_Request $request) {
|
||||
$data = [
|
||||
'title' => $request->get_param('title'),
|
||||
'subject' => $request->get_param('subject'),
|
||||
'content' => $request->get_param('content'),
|
||||
'status' => $request->get_param('status') ?: 'draft',
|
||||
'scheduled_at' => $request->get_param('scheduled_at'),
|
||||
];
|
||||
|
||||
$campaign_id = CampaignManager::create($data);
|
||||
|
||||
if (is_wp_error($campaign_id)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => $campaign_id->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$campaign = CampaignManager::get($campaign_id);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $campaign,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single campaign
|
||||
*/
|
||||
public static function get_campaign(WP_REST_Request $request) {
|
||||
$campaign_id = (int) $request->get_param('id');
|
||||
$campaign = CampaignManager::get($campaign_id);
|
||||
|
||||
if (!$campaign) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => __('Campaign not found', 'woonoow'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $campaign,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update campaign
|
||||
*/
|
||||
public static function update_campaign(WP_REST_Request $request) {
|
||||
$campaign_id = (int) $request->get_param('id');
|
||||
|
||||
$data = [];
|
||||
|
||||
if ($request->has_param('title')) {
|
||||
$data['title'] = $request->get_param('title');
|
||||
}
|
||||
if ($request->has_param('subject')) {
|
||||
$data['subject'] = $request->get_param('subject');
|
||||
}
|
||||
if ($request->has_param('content')) {
|
||||
$data['content'] = $request->get_param('content');
|
||||
}
|
||||
if ($request->has_param('status')) {
|
||||
$data['status'] = $request->get_param('status');
|
||||
}
|
||||
if ($request->has_param('scheduled_at')) {
|
||||
$data['scheduled_at'] = $request->get_param('scheduled_at');
|
||||
}
|
||||
|
||||
$result = CampaignManager::update($campaign_id, $data);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$campaign = CampaignManager::get($campaign_id);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $campaign,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete campaign
|
||||
*/
|
||||
public static function delete_campaign(WP_REST_Request $request) {
|
||||
$campaign_id = (int) $request->get_param('id');
|
||||
|
||||
$result = CampaignManager::delete($campaign_id);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => __('Failed to delete campaign', 'woonoow'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => __('Campaign deleted', 'woonoow'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send campaign
|
||||
*/
|
||||
public static function send_campaign(WP_REST_Request $request) {
|
||||
$campaign_id = (int) $request->get_param('id');
|
||||
|
||||
$result = CampaignManager::send($campaign_id);
|
||||
|
||||
if (!$result['success']) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
], 400);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
__('Campaign sent to %d recipients (%d failed)', 'woonoow'),
|
||||
$result['sent'],
|
||||
$result['failed']
|
||||
),
|
||||
'sent' => $result['sent'],
|
||||
'failed' => $result['failed'],
|
||||
'total' => $result['total'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email
|
||||
*/
|
||||
public static function send_test_email(WP_REST_Request $request) {
|
||||
$campaign_id = (int) $request->get_param('id');
|
||||
$email = sanitize_email($request->get_param('email'));
|
||||
|
||||
if (!is_email($email)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => __('Invalid email address', 'woonoow'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = CampaignManager::send_test($campaign_id, $email);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => __('Failed to send test email', 'woonoow'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview campaign
|
||||
*/
|
||||
public static function preview_campaign(WP_REST_Request $request) {
|
||||
$campaign_id = (int) $request->get_param('id');
|
||||
$campaign = CampaignManager::get($campaign_id);
|
||||
|
||||
if (!$campaign) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => __('Campaign not found', 'woonoow'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Use reflection to call private render method or make it public
|
||||
// For now, return a simple preview
|
||||
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
||||
|
||||
$content = $campaign['content'];
|
||||
$subject = $campaign['subject'] ?: $campaign['title'];
|
||||
|
||||
if ($template) {
|
||||
$content = str_replace('{content}', $campaign['content'], $template['body']);
|
||||
$content = str_replace('{campaign_title}', $campaign['title'], $content);
|
||||
}
|
||||
|
||||
// Replace placeholders
|
||||
$site_name = get_bloginfo('name');
|
||||
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
|
||||
$content = str_replace('{site_url}', home_url(), $content);
|
||||
$content = str_replace('{subscriber_email}', 'subscriber@example.com', $content);
|
||||
$content = str_replace('{unsubscribe_url}', '#unsubscribe', $content);
|
||||
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
|
||||
$content = str_replace('{current_year}', date('Y'), $content);
|
||||
|
||||
// Render with design template
|
||||
$design_path = $renderer->get_design_template();
|
||||
if (file_exists($design_path)) {
|
||||
$content = $renderer->render_html($design_path, $content, $subject, [
|
||||
'site_name' => $site_name,
|
||||
'site_url' => home_url(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'subject' => $subject,
|
||||
'html' => $content,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,18 @@ class CheckoutController {
|
||||
'callback' => [ new self(), 'get_fields' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||
]);
|
||||
// Public order view endpoint for thank you page
|
||||
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [ new self(), 'get_order' ],
|
||||
'permission_callback' => '__return_true', // Public, validated via order_key
|
||||
'args' => [
|
||||
'key' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +145,69 @@ class CheckoutController {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public order view endpoint for thank you page
|
||||
* Validates access via order_key (for guests) or logged-in customer ID
|
||||
* GET /checkout/order/{id}?key=wc_order_xxx
|
||||
*/
|
||||
public function get_order(WP_REST_Request $r): array {
|
||||
$order_id = absint($r['id']);
|
||||
$order_key = sanitize_text_field($r->get_param('key') ?? '');
|
||||
|
||||
if (!$order_id) {
|
||||
return ['error' => __('Invalid order ID', 'woonoow')];
|
||||
}
|
||||
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order) {
|
||||
return ['error' => __('Order not found', 'woonoow')];
|
||||
}
|
||||
|
||||
// Validate access: order_key must match OR user must be logged in and own the order
|
||||
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
|
||||
$valid_owner = is_user_logged_in() && get_current_user_id() === $order->get_customer_id();
|
||||
|
||||
if (!$valid_key && !$valid_owner) {
|
||||
return ['error' => __('Unauthorized access to order', 'woonoow')];
|
||||
}
|
||||
|
||||
// Build order items
|
||||
$items = [];
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
$items[] = [
|
||||
'id' => $item->get_id(),
|
||||
'product_id' => $product ? $product->get_id() : 0,
|
||||
'name' => $item->get_name(),
|
||||
'qty' => (int) $item->get_quantity(),
|
||||
'price' => (float) $item->get_total() / max(1, $item->get_quantity()),
|
||||
'total' => (float) $item->get_total(),
|
||||
'image' => $product ? wp_get_attachment_image_url($product->get_image_id(), 'thumbnail') : null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'id' => $order->get_id(),
|
||||
'number' => $order->get_order_number(),
|
||||
'status' => $order->get_status(),
|
||||
'subtotal' => (float) $order->get_subtotal(),
|
||||
'shipping_total' => (float) $order->get_shipping_total(),
|
||||
'tax_total' => (float) $order->get_total_tax(),
|
||||
'total' => (float) $order->get_total(),
|
||||
'currency' => $order->get_currency(),
|
||||
'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()),
|
||||
'payment_method' => $order->get_payment_method_title(),
|
||||
'billing' => [
|
||||
'first_name' => $order->get_billing_first_name(),
|
||||
'last_name' => $order->get_billing_last_name(),
|
||||
'email' => $order->get_billing_email(),
|
||||
'phone' => $order->get_billing_phone(),
|
||||
],
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an order:
|
||||
* {
|
||||
@@ -187,6 +262,68 @@ class CheckoutController {
|
||||
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Guest checkout - check if auto-register is enabled
|
||||
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
|
||||
$auto_register = $customer_settings['auto_register_members'] ?? false;
|
||||
|
||||
if ($auto_register && !empty($payload['billing']['email'])) {
|
||||
$email = sanitize_email($payload['billing']['email']);
|
||||
|
||||
// Check if user already exists
|
||||
$existing_user = get_user_by('email', $email);
|
||||
|
||||
if ($existing_user) {
|
||||
// User exists - link order to them
|
||||
$order->set_customer_id($existing_user->ID);
|
||||
} else {
|
||||
// Create new user account
|
||||
$password = wp_generate_password(12, true, true);
|
||||
|
||||
$userdata = [
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'user_pass' => $password,
|
||||
'first_name' => sanitize_text_field($payload['billing']['first_name'] ?? ''),
|
||||
'last_name' => sanitize_text_field($payload['billing']['last_name'] ?? ''),
|
||||
'display_name' => trim((sanitize_text_field($payload['billing']['first_name'] ?? '') . ' ' . sanitize_text_field($payload['billing']['last_name'] ?? ''))) ?: $email,
|
||||
'role' => 'customer', // WooCommerce customer role
|
||||
];
|
||||
|
||||
$new_user_id = wp_insert_user($userdata);
|
||||
|
||||
if (!is_wp_error($new_user_id)) {
|
||||
// Link order to new user
|
||||
$order->set_customer_id($new_user_id);
|
||||
|
||||
// Store temp password in user meta for email template
|
||||
// The real password is already set via wp_insert_user
|
||||
update_user_meta($new_user_id, '_woonoow_temp_password', $password);
|
||||
|
||||
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
|
||||
wp_set_auth_cookie($new_user_id, true);
|
||||
wp_set_current_user($new_user_id);
|
||||
|
||||
// Set WooCommerce customer billing data
|
||||
$customer = new \WC_Customer($new_user_id);
|
||||
|
||||
if (!empty($payload['billing']['first_name'])) $customer->set_billing_first_name(sanitize_text_field($payload['billing']['first_name']));
|
||||
if (!empty($payload['billing']['last_name'])) $customer->set_billing_last_name(sanitize_text_field($payload['billing']['last_name']));
|
||||
if (!empty($payload['billing']['email'])) $customer->set_billing_email(sanitize_email($payload['billing']['email']));
|
||||
if (!empty($payload['billing']['phone'])) $customer->set_billing_phone(sanitize_text_field($payload['billing']['phone']));
|
||||
if (!empty($payload['billing']['address_1'])) $customer->set_billing_address_1(sanitize_text_field($payload['billing']['address_1']));
|
||||
if (!empty($payload['billing']['city'])) $customer->set_billing_city(sanitize_text_field($payload['billing']['city']));
|
||||
if (!empty($payload['billing']['state'])) $customer->set_billing_state(sanitize_text_field($payload['billing']['state']));
|
||||
if (!empty($payload['billing']['postcode'])) $customer->set_billing_postcode(sanitize_text_field($payload['billing']['postcode']));
|
||||
if (!empty($payload['billing']['country'])) $customer->set_billing_country(sanitize_text_field($payload['billing']['country']));
|
||||
|
||||
$customer->save();
|
||||
|
||||
// Send new account email (WooCommerce will handle this automatically via hook)
|
||||
do_action('woocommerce_created_customer', $new_user_id, $userdata, $password);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add items
|
||||
@@ -265,6 +402,12 @@ class CheckoutController {
|
||||
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
||||
}
|
||||
|
||||
// Clear WooCommerce cart after successful order placement
|
||||
// This ensures the cart page won't re-populate from server session
|
||||
if (function_exists('WC') && WC()->cart) {
|
||||
WC()->cart->empty_cart();
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'order_id' => $order->get_id(),
|
||||
|
||||
296
includes/Api/ModuleSettingsController.php
Normal file
296
includes/Api/ModuleSettingsController.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
/**
|
||||
* Module Settings REST API Controller
|
||||
*
|
||||
* Handles module-specific settings storage and retrieval
|
||||
*
|
||||
* @package WooNooW\Api
|
||||
*/
|
||||
|
||||
namespace WooNooW\Api;
|
||||
|
||||
use WP_REST_Controller;
|
||||
use WP_REST_Server;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
|
||||
class ModuleSettingsController extends WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* REST API namespace
|
||||
*/
|
||||
protected $namespace = 'woonoow/v1';
|
||||
|
||||
/**
|
||||
* REST API base
|
||||
*/
|
||||
protected $rest_base = 'modules';
|
||||
|
||||
/**
|
||||
* Register routes
|
||||
*/
|
||||
public function register_routes() {
|
||||
// GET /woonoow/v1/modules/{module_id}/settings (public - needed by frontend)
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<module_id>[a-zA-Z0-9_-]+)/settings', [
|
||||
[
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_settings'],
|
||||
'permission_callback' => '__return_true', // Public: settings are non-sensitive, needed by customer pages
|
||||
'args' => [
|
||||
'module_id' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// POST /woonoow/v1/modules/{module_id}/settings
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<module_id>[a-zA-Z0-9_-]+)/settings', [
|
||||
[
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'update_settings'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'module_id' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// GET /woonoow/v1/modules/{module_id}/schema
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<module_id>[a-zA-Z0-9_-]+)/schema', [
|
||||
[
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_schema'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'module_id' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permission
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function check_permission() {
|
||||
return current_user_can('manage_options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module settings
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function get_settings($request) {
|
||||
$module_id = $request['module_id'];
|
||||
|
||||
// Verify module exists
|
||||
$modules = ModuleRegistry::get_all_modules();
|
||||
if (!isset($modules[$module_id])) {
|
||||
return new WP_Error(
|
||||
'invalid_module',
|
||||
__('Invalid module ID', 'woonoow'),
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
|
||||
// Get settings from database
|
||||
$settings = get_option("woonoow_module_{$module_id}_settings", []);
|
||||
|
||||
// Apply defaults from schema if available
|
||||
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||
if (isset($schema[$module_id])) {
|
||||
$defaults = $this->get_schema_defaults($schema[$module_id]);
|
||||
$settings = wp_parse_args($settings, $defaults);
|
||||
}
|
||||
|
||||
return new WP_REST_Response($settings, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update module settings
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function update_settings($request) {
|
||||
$module_id = $request['module_id'];
|
||||
$new_settings = $request->get_json_params();
|
||||
|
||||
// Verify module exists
|
||||
$modules = ModuleRegistry::get_all_modules();
|
||||
if (!isset($modules[$module_id])) {
|
||||
return new WP_Error(
|
||||
'invalid_module',
|
||||
__('Invalid module ID', 'woonoow'),
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
|
||||
// Validate against schema if available
|
||||
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||
if (isset($schema[$module_id])) {
|
||||
$validated = $this->validate_settings($new_settings, $schema[$module_id]);
|
||||
if (is_wp_error($validated)) {
|
||||
return $validated;
|
||||
}
|
||||
$new_settings = $validated;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
update_option("woonoow_module_{$module_id}_settings", $new_settings);
|
||||
|
||||
// Allow addons to react to settings changes
|
||||
do_action("woonoow/module_settings_updated/{$module_id}", $new_settings);
|
||||
do_action('woonoow/module_settings_updated', $module_id, $new_settings);
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'message' => __('Settings saved successfully', 'woonoow'),
|
||||
'settings' => $new_settings,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings schema for a module
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function get_schema($request) {
|
||||
$module_id = $request['module_id'];
|
||||
|
||||
// Verify module exists
|
||||
$modules = ModuleRegistry::get_all_modules();
|
||||
if (!isset($modules[$module_id])) {
|
||||
return new WP_Error(
|
||||
'invalid_module',
|
||||
__('Invalid module ID', 'woonoow'),
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
|
||||
// Get schema from filter
|
||||
$all_schemas = apply_filters('woonoow/module_settings_schema', []);
|
||||
$schema = $all_schemas[$module_id] ?? null;
|
||||
|
||||
if (!$schema) {
|
||||
return new WP_REST_Response([
|
||||
'schema' => null,
|
||||
'message' => __('No schema available for this module', 'woonoow'),
|
||||
], 200);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'schema' => $schema,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values from schema
|
||||
*
|
||||
* @param array $schema
|
||||
* @return array
|
||||
*/
|
||||
private function get_schema_defaults($schema) {
|
||||
$defaults = [];
|
||||
|
||||
foreach ($schema as $key => $field) {
|
||||
if (isset($field['default'])) {
|
||||
$defaults[$key] = $field['default'];
|
||||
}
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate settings against schema
|
||||
*
|
||||
* @param array $settings
|
||||
* @param array $schema
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
private function validate_settings($settings, $schema) {
|
||||
$validated = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($schema as $key => $field) {
|
||||
$value = $settings[$key] ?? null;
|
||||
|
||||
// Check required fields
|
||||
if (!empty($field['required']) && ($value === null || $value === '')) {
|
||||
$errors[$key] = sprintf(
|
||||
__('%s is required', 'woonoow'),
|
||||
$field['label'] ?? $key
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip validation if value is null and not required
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
$type = $field['type'] ?? 'text';
|
||||
switch ($type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'email':
|
||||
case 'url':
|
||||
$validated[$key] = sanitize_text_field($value);
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
$validated[$key] = floatval($value);
|
||||
break;
|
||||
|
||||
case 'toggle':
|
||||
case 'checkbox':
|
||||
$validated[$key] = (bool) $value;
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
// Validate against allowed options
|
||||
if (isset($field['options']) && !isset($field['options'][$value])) {
|
||||
$errors[$key] = sprintf(
|
||||
__('Invalid value for %s', 'woonoow'),
|
||||
$field['label'] ?? $key
|
||||
);
|
||||
} else {
|
||||
$validated[$key] = sanitize_text_field($value);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$validated[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new WP_Error(
|
||||
'validation_failed',
|
||||
__('Settings validation failed', 'woonoow'),
|
||||
['status' => 400, 'errors' => $errors]
|
||||
);
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
}
|
||||
@@ -86,24 +86,20 @@ class ModulesController extends WP_REST_Controller {
|
||||
*/
|
||||
public function get_modules($request) {
|
||||
$modules = ModuleRegistry::get_all_with_status();
|
||||
$grouped = ModuleRegistry::get_grouped_modules();
|
||||
|
||||
// Group by category
|
||||
$grouped = [
|
||||
'marketing' => [],
|
||||
'customers' => [],
|
||||
'products' => [],
|
||||
];
|
||||
|
||||
foreach ($modules as $module) {
|
||||
$category = $module['category'];
|
||||
if (isset($grouped[$category])) {
|
||||
$grouped[$category][] = $module;
|
||||
// Add enabled status to grouped modules
|
||||
$enabled_modules = ModuleRegistry::get_enabled_modules();
|
||||
foreach ($grouped as $category => &$category_modules) {
|
||||
foreach ($category_modules as &$module) {
|
||||
$module['enabled'] = in_array($module['id'], $enabled_modules);
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'modules' => $modules,
|
||||
'grouped' => $grouped,
|
||||
'categories' => ModuleRegistry::get_categories(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
@@ -117,9 +113,25 @@ class ModulesController extends WP_REST_Controller {
|
||||
$module_id = $request->get_param('module_id');
|
||||
$enabled = $request->get_param('enabled');
|
||||
|
||||
$modules = ModuleRegistry::get_all_modules();
|
||||
if (empty($module_id)) {
|
||||
return new WP_Error(
|
||||
'missing_module_id',
|
||||
__('Module ID is required', 'woonoow'),
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
if (!isset($modules[$module_id])) {
|
||||
// Get all modules to validate module_id
|
||||
$all_modules = ModuleRegistry::get_all_modules();
|
||||
$module_exists = false;
|
||||
foreach ($all_modules as $module) {
|
||||
if ($module['id'] === $module_id) {
|
||||
$module_exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$module_exists) {
|
||||
return new WP_Error(
|
||||
'invalid_module',
|
||||
__('Invalid module ID', 'woonoow'),
|
||||
@@ -127,28 +139,19 @@ class ModulesController extends WP_REST_Controller {
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle module
|
||||
if ($enabled) {
|
||||
$result = ModuleRegistry::enable($module_id);
|
||||
ModuleRegistry::enable_module($module_id);
|
||||
} else {
|
||||
$result = ModuleRegistry::disable($module_id);
|
||||
ModuleRegistry::disable_module($module_id);
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => $enabled
|
||||
? __('Module enabled successfully', 'woonoow')
|
||||
: __('Module disabled successfully', 'woonoow'),
|
||||
'module_id' => $module_id,
|
||||
'enabled' => $enabled,
|
||||
], 200);
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'toggle_failed',
|
||||
__('Failed to toggle module', 'woonoow'),
|
||||
['status' => 500]
|
||||
);
|
||||
// Return success response
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'module_id' => $module_id,
|
||||
'enabled' => $enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,6 +56,23 @@ class NewsletterController {
|
||||
return current_user_can('manage_options');
|
||||
},
|
||||
]);
|
||||
|
||||
// Public unsubscribe endpoint (no auth needed, uses token)
|
||||
register_rest_route(self::API_NAMESPACE, '/newsletter/unsubscribe', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'unsubscribe'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'email' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
],
|
||||
'token' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function get_template(WP_REST_Request $request) {
|
||||
@@ -197,4 +214,78 @@ class NewsletterController {
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unsubscribe request
|
||||
*/
|
||||
public static function unsubscribe(WP_REST_Request $request) {
|
||||
$email = sanitize_email(urldecode($request->get_param('email')));
|
||||
$token = sanitize_text_field($request->get_param('token'));
|
||||
|
||||
// Verify token
|
||||
$expected_token = self::generate_unsubscribe_token($email);
|
||||
if (!hash_equals($expected_token, $token)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => __('Invalid unsubscribe link', 'woonoow'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Get subscribers
|
||||
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
||||
$found = false;
|
||||
|
||||
foreach ($subscribers as &$sub) {
|
||||
if (isset($sub['email']) && $sub['email'] === $email) {
|
||||
$sub['status'] = 'unsubscribed';
|
||||
$sub['unsubscribed_at'] = current_time('mysql');
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => __('Email not found', 'woonoow'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
update_option('woonoow_newsletter_subscribers', $subscribers);
|
||||
|
||||
do_action('woonoow_newsletter_unsubscribed', $email);
|
||||
|
||||
// Return HTML page for nice UX
|
||||
$site_name = get_bloginfo('name');
|
||||
$html = sprintf(
|
||||
'<!DOCTYPE html><html><head><title>%s</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;}.box{background:white;padding:40px;border-radius:8px;text-align:center;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:400px;}h1{color:#333;margin-bottom:16px;}p{color:#666;}</style></head><body><div class="box"><h1>✓ Unsubscribed</h1><p>You have been unsubscribed from %s newsletter.</p></div></body></html>',
|
||||
__('Unsubscribed', 'woonoow'),
|
||||
esc_html($site_name)
|
||||
);
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure unsubscribe token
|
||||
*/
|
||||
private static function generate_unsubscribe_token($email) {
|
||||
$secret = wp_salt('auth');
|
||||
return hash_hmac('sha256', $email, $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unsubscribe URL for email templates
|
||||
*/
|
||||
public static function generate_unsubscribe_url($email) {
|
||||
$token = self::generate_unsubscribe_token($email);
|
||||
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
|
||||
return add_query_arg([
|
||||
'email' => urlencode($email),
|
||||
'token' => $token,
|
||||
], $base_url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class NotificationsController {
|
||||
],
|
||||
]);
|
||||
|
||||
// GET/PUT /woonoow/v1/notifications/templates/:eventId/:channelId
|
||||
// GET/POST /woonoow/v1/notifications/templates/:eventId/:channelId
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
@@ -77,7 +77,7 @@ class NotificationsController {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
[
|
||||
'methods' => 'PUT',
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'save_template'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
@@ -486,6 +486,9 @@ class NotificationsController {
|
||||
}
|
||||
}
|
||||
|
||||
// Add available variables for this event (contextual)
|
||||
$template['available_variables'] = EventRegistry::get_variables_for_event($event_id, $recipient_type);
|
||||
|
||||
return new WP_REST_Response($template, 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,6 @@ class Permissions {
|
||||
$has_wc = current_user_can('manage_woocommerce');
|
||||
$has_opts = current_user_can('manage_options');
|
||||
$result = $has_wc || $has_opts;
|
||||
error_log(sprintf('WooNooW Permissions: check_admin_permission() - WC:%s Options:%s Result:%s',
|
||||
$has_wc ? 'YES' : 'NO',
|
||||
$has_opts ? 'YES' : 'NO',
|
||||
$result ? 'ALLOWED' : 'DENIED'
|
||||
));
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,69 @@ class ProductsController {
|
||||
'callback' => [__CLASS__, 'get_attributes'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Create category
|
||||
register_rest_route('woonoow/v1', '/products/categories', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_category'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Update category
|
||||
register_rest_route('woonoow/v1', '/products/categories/(?P<id>\d+)', [
|
||||
'methods' => 'PUT',
|
||||
'callback' => [__CLASS__, 'update_category'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Delete category
|
||||
register_rest_route('woonoow/v1', '/products/categories/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_category'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Create tag
|
||||
register_rest_route('woonoow/v1', '/products/tags', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_tag'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Update tag
|
||||
register_rest_route('woonoow/v1', '/products/tags/(?P<id>\d+)', [
|
||||
'methods' => 'PUT',
|
||||
'callback' => [__CLASS__, 'update_tag'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Delete tag
|
||||
register_rest_route('woonoow/v1', '/products/tags/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_tag'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Create attribute
|
||||
register_rest_route('woonoow/v1', '/products/attributes', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_attribute'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Update attribute
|
||||
register_rest_route('woonoow/v1', '/products/attributes/(?P<id>\d+)', [
|
||||
'methods' => 'PUT',
|
||||
'callback' => [__CLASS__, 'update_attribute'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Delete attribute
|
||||
register_rest_route('woonoow/v1', '/products/attributes/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_attribute'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,6 +447,7 @@ class ProductsController {
|
||||
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
|
||||
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
|
||||
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
|
||||
|
||||
if (isset($data['regular_price'])) $product->set_regular_price(self::sanitize_number($data['regular_price']));
|
||||
if (isset($data['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
|
||||
|
||||
@@ -512,9 +576,10 @@ class ProductsController {
|
||||
$categories = [];
|
||||
foreach ($terms as $term) {
|
||||
$categories[] = [
|
||||
'id' => $term->term_id,
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'description' => $term->description,
|
||||
'parent' => $term->parent,
|
||||
'count' => $term->count,
|
||||
];
|
||||
@@ -539,9 +604,10 @@ class ProductsController {
|
||||
$tags = [];
|
||||
foreach ($terms as $term) {
|
||||
$tags[] = [
|
||||
'id' => $term->term_id,
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'description' => $term->description,
|
||||
'count' => $term->count,
|
||||
];
|
||||
}
|
||||
@@ -558,11 +624,12 @@ class ProductsController {
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$result[] = [
|
||||
'id' => $attribute->attribute_id,
|
||||
'name' => $attribute->attribute_name,
|
||||
'label' => $attribute->attribute_label,
|
||||
'type' => $attribute->attribute_type,
|
||||
'orderby' => $attribute->attribute_orderby,
|
||||
'attribute_id' => $attribute->attribute_id,
|
||||
'attribute_name' => $attribute->attribute_name,
|
||||
'attribute_label' => $attribute->attribute_label,
|
||||
'attribute_type' => $attribute->attribute_type,
|
||||
'attribute_orderby' => $attribute->attribute_orderby,
|
||||
'attribute_public' => $attribute->attribute_public,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -734,15 +801,18 @@ class ProductsController {
|
||||
$value = $term ? $term->name : $value;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
// Custom attribute - stored as lowercase in meta
|
||||
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||
$value = get_post_meta($variation_id, $meta_key, true);
|
||||
|
||||
// Capitalize the attribute name for display
|
||||
// Capitalize the attribute name for display to match admin SPA
|
||||
$clean_name = ucfirst($attr_name);
|
||||
}
|
||||
|
||||
$formatted_attributes[$clean_name] = $value;
|
||||
// Only add if value exists
|
||||
if (!empty($value)) {
|
||||
$formatted_attributes[$clean_name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$image_url = $image ? $image[0] : '';
|
||||
@@ -791,36 +861,106 @@ class ProductsController {
|
||||
* Save product variations
|
||||
*/
|
||||
private static function save_product_variations($product, $variations_data) {
|
||||
// Get existing variation IDs
|
||||
$existing_variation_ids = $product->get_children();
|
||||
$variations_to_keep = [];
|
||||
|
||||
foreach ($variations_data as $var_data) {
|
||||
if (isset($var_data['id']) && $var_data['id']) {
|
||||
// Update existing variation
|
||||
$variation = wc_get_product($var_data['id']);
|
||||
if (!$variation) continue;
|
||||
$variations_to_keep[] = $var_data['id'];
|
||||
} else {
|
||||
// Create new variation
|
||||
$variation = new WC_Product_Variation();
|
||||
$variation->set_parent_id($product->get_id());
|
||||
}
|
||||
|
||||
if ($variation) {
|
||||
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
||||
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']);
|
||||
if (isset($var_data['sale_price'])) $variation->set_sale_price($var_data['sale_price']);
|
||||
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
|
||||
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
|
||||
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
|
||||
if (isset($var_data['attributes'])) $variation->set_attributes($var_data['attributes']);
|
||||
// Build attributes array
|
||||
$wc_attributes = [];
|
||||
if (isset($var_data['attributes']) && is_array($var_data['attributes'])) {
|
||||
$parent_attributes = $product->get_attributes();
|
||||
|
||||
// Handle image - support both image_id and image URL
|
||||
if (isset($var_data['image']) && !empty($var_data['image'])) {
|
||||
$image_id = attachment_url_to_postid($var_data['image']);
|
||||
if ($image_id) {
|
||||
$variation->set_image_id($image_id);
|
||||
foreach ($var_data['attributes'] as $display_name => $value) {
|
||||
if (empty($value)) continue;
|
||||
|
||||
foreach ($parent_attributes as $attr_name => $parent_attr) {
|
||||
if (!$parent_attr->get_variation()) continue;
|
||||
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
|
||||
$wc_attributes[strtolower($attr_name)] = strtolower($value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (isset($var_data['image_id'])) {
|
||||
$variation->set_image_id($var_data['image_id']);
|
||||
}
|
||||
}
|
||||
|
||||
$variation->save();
|
||||
if (!empty($wc_attributes)) {
|
||||
$variation->set_attributes($wc_attributes);
|
||||
}
|
||||
|
||||
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
||||
|
||||
// Set prices - if not provided, use parent's price as fallback
|
||||
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
|
||||
$variation->set_regular_price($var_data['regular_price']);
|
||||
} elseif (!$variation->get_regular_price()) {
|
||||
// Fallback to parent price if variation has no price
|
||||
$parent_price = $product->get_regular_price();
|
||||
if ($parent_price) {
|
||||
$variation->set_regular_price($parent_price);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($var_data['sale_price']) && $var_data['sale_price'] !== '') {
|
||||
$variation->set_sale_price($var_data['sale_price']);
|
||||
}
|
||||
|
||||
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
|
||||
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
|
||||
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
|
||||
|
||||
if (isset($var_data['image']) && !empty($var_data['image'])) {
|
||||
$image_id = attachment_url_to_postid($var_data['image']);
|
||||
if ($image_id) $variation->set_image_id($image_id);
|
||||
} elseif (isset($var_data['image_id'])) {
|
||||
$variation->set_image_id($var_data['image_id']);
|
||||
}
|
||||
|
||||
// Save variation first
|
||||
$saved_id = $variation->save();
|
||||
$variations_to_keep[] = $saved_id;
|
||||
|
||||
// Manually save attributes using direct database insert
|
||||
if (!empty($wc_attributes)) {
|
||||
global $wpdb;
|
||||
|
||||
foreach ($wc_attributes as $attr_name => $attr_value) {
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
|
||||
$wpdb->delete(
|
||||
$wpdb->postmeta,
|
||||
['post_id' => $saved_id, 'meta_key' => $meta_key],
|
||||
['%d', '%s']
|
||||
);
|
||||
|
||||
$wpdb->insert(
|
||||
$wpdb->postmeta,
|
||||
[
|
||||
'post_id' => $saved_id,
|
||||
'meta_key' => $meta_key,
|
||||
'meta_value' => $attr_value
|
||||
],
|
||||
['%d', '%s', '%s']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete variations that are no longer in the list
|
||||
$variations_to_delete = array_diff($existing_variation_ids, $variations_to_keep);
|
||||
foreach ($variations_to_delete as $variation_id) {
|
||||
$variation_to_delete = wc_get_product($variation_id);
|
||||
if ($variation_to_delete) {
|
||||
$variation_to_delete->delete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,13 +995,6 @@ class ProductsController {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Private meta (starts with _) - check if allowed
|
||||
// Core has ZERO defaults - plugins register via filter
|
||||
$allowed_private = apply_filters('woonoow/product_allowed_private_meta', [], $product);
|
||||
|
||||
if (in_array($key, $allowed_private, true)) {
|
||||
$meta_data[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $meta_data;
|
||||
@@ -901,4 +1034,357 @@ class ProductsController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create product category
|
||||
*/
|
||||
public static function create_category(WP_REST_Request $request) {
|
||||
try {
|
||||
$name = sanitize_text_field($request->get_param('name'));
|
||||
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
||||
$description = sanitize_textarea_field($request->get_param('description') ?: '');
|
||||
$parent = (int) ($request->get_param('parent') ?: 0);
|
||||
|
||||
$result = wp_insert_term($name, 'product_cat', [
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'parent' => $parent,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$term = get_term($result['term_id'], 'product_cat');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'description' => $term->description,
|
||||
'parent' => $term->parent,
|
||||
'count' => $term->count,
|
||||
],
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update product category
|
||||
*/
|
||||
public static function update_category(WP_REST_Request $request) {
|
||||
try {
|
||||
$term_id = (int) $request->get_param('id');
|
||||
$name = sanitize_text_field($request->get_param('name'));
|
||||
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
||||
$description = sanitize_textarea_field($request->get_param('description') ?: '');
|
||||
$parent = (int) ($request->get_param('parent') ?: 0);
|
||||
|
||||
$result = wp_update_term($term_id, 'product_cat', [
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'parent' => $parent,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$term = get_term($term_id, 'product_cat');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'description' => $term->description,
|
||||
'parent' => $term->parent,
|
||||
'count' => $term->count,
|
||||
],
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete product category
|
||||
*/
|
||||
public static function delete_category(WP_REST_Request $request) {
|
||||
try {
|
||||
$term_id = (int) $request->get_param('id');
|
||||
|
||||
$result = wp_delete_term($term_id, 'product_cat');
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Category deleted successfully',
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create product tag
|
||||
*/
|
||||
public static function create_tag(WP_REST_Request $request) {
|
||||
try {
|
||||
$name = sanitize_text_field($request->get_param('name'));
|
||||
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
||||
$description = sanitize_textarea_field($request->get_param('description') ?: '');
|
||||
|
||||
$result = wp_insert_term($name, 'product_tag', [
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$term = get_term($result['term_id'], 'product_tag');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'description' => $term->description,
|
||||
'count' => $term->count,
|
||||
],
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update product tag
|
||||
*/
|
||||
public static function update_tag(WP_REST_Request $request) {
|
||||
try {
|
||||
$term_id = (int) $request->get_param('id');
|
||||
$name = sanitize_text_field($request->get_param('name'));
|
||||
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
||||
$description = sanitize_textarea_field($request->get_param('description') ?: '');
|
||||
|
||||
$result = wp_update_term($term_id, 'product_tag', [
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$term = get_term($term_id, 'product_tag');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'description' => $term->description,
|
||||
'count' => $term->count,
|
||||
],
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete product tag
|
||||
*/
|
||||
public static function delete_tag(WP_REST_Request $request) {
|
||||
try {
|
||||
$term_id = (int) $request->get_param('id');
|
||||
|
||||
$result = wp_delete_term($term_id, 'product_tag');
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Tag deleted successfully',
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create product attribute
|
||||
*/
|
||||
public static function create_attribute(WP_REST_Request $request) {
|
||||
try {
|
||||
$label = sanitize_text_field($request->get_param('label'));
|
||||
$name = sanitize_title($request->get_param('name') ?: $label);
|
||||
$type = sanitize_text_field($request->get_param('type') ?: 'select');
|
||||
$orderby = sanitize_text_field($request->get_param('orderby') ?: 'menu_order');
|
||||
$public = (int) ($request->get_param('public') ?: 1);
|
||||
|
||||
$attribute_id = wc_create_attribute([
|
||||
'name' => $label,
|
||||
'slug' => $name,
|
||||
'type' => $type,
|
||||
'order_by' => $orderby,
|
||||
'has_archives' => $public,
|
||||
]);
|
||||
|
||||
if (is_wp_error($attribute_id)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $attribute_id->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$attribute = wc_get_attribute($attribute_id);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'attribute_id' => $attribute->id,
|
||||
'attribute_name' => $attribute->slug,
|
||||
'attribute_label' => $attribute->name,
|
||||
'attribute_type' => $attribute->type,
|
||||
'attribute_orderby' => $attribute->order_by,
|
||||
'attribute_public' => $attribute->has_archives,
|
||||
],
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update product attribute
|
||||
*/
|
||||
public static function update_attribute(WP_REST_Request $request) {
|
||||
try {
|
||||
$attribute_id = (int) $request->get_param('id');
|
||||
$label = sanitize_text_field($request->get_param('label'));
|
||||
$name = sanitize_title($request->get_param('name') ?: $label);
|
||||
$type = sanitize_text_field($request->get_param('type') ?: 'select');
|
||||
$orderby = sanitize_text_field($request->get_param('orderby') ?: 'menu_order');
|
||||
$public = (int) ($request->get_param('public') ?: 1);
|
||||
|
||||
$result = wc_update_attribute($attribute_id, [
|
||||
'name' => $label,
|
||||
'slug' => $name,
|
||||
'type' => $type,
|
||||
'order_by' => $orderby,
|
||||
'has_archives' => $public,
|
||||
]);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
$attribute = wc_get_attribute($attribute_id);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'attribute_id' => $attribute->id,
|
||||
'attribute_name' => $attribute->slug,
|
||||
'attribute_label' => $attribute->name,
|
||||
'attribute_type' => $attribute->type,
|
||||
'attribute_orderby' => $attribute->order_by,
|
||||
'attribute_public' => $attribute->has_archives,
|
||||
],
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete product attribute
|
||||
*/
|
||||
public static function delete_attribute(WP_REST_Request $request) {
|
||||
try {
|
||||
$attribute_id = (int) $request->get_param('id');
|
||||
|
||||
$result = wc_delete_attribute($attribute_id);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $result->get_error_message(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Attribute deleted successfully',
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ use WooNooW\Api\CouponsController;
|
||||
use WooNooW\Api\CustomersController;
|
||||
use WooNooW\Api\NewsletterController;
|
||||
use WooNooW\Api\ModulesController;
|
||||
use WooNooW\Api\ModuleSettingsController;
|
||||
use WooNooW\Api\CampaignsController;
|
||||
use WooNooW\Frontend\ShopController;
|
||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||
use WooNooW\Frontend\AccountController;
|
||||
@@ -63,6 +65,34 @@ class Routes {
|
||||
'permission_callback' => '__return_true',
|
||||
] );
|
||||
|
||||
// Customer login endpoint (no admin permission required)
|
||||
register_rest_route( $namespace, '/auth/customer-login', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ AuthController::class, 'customer_login' ],
|
||||
'permission_callback' => '__return_true',
|
||||
] );
|
||||
|
||||
// Forgot password endpoint (public)
|
||||
register_rest_route( $namespace, '/auth/forgot-password', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ AuthController::class, 'forgot_password' ],
|
||||
'permission_callback' => '__return_true',
|
||||
] );
|
||||
|
||||
// Validate password reset key (public)
|
||||
register_rest_route( $namespace, '/auth/validate-reset-key', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ AuthController::class, 'validate_reset_key' ],
|
||||
'permission_callback' => '__return_true',
|
||||
] );
|
||||
|
||||
// Reset password with key (public)
|
||||
register_rest_route( $namespace, '/auth/reset-password', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ AuthController::class, 'reset_password' ],
|
||||
'permission_callback' => '__return_true',
|
||||
] );
|
||||
|
||||
// Defer to controllers to register their endpoints
|
||||
CheckoutController::register();
|
||||
OrdersController::register();
|
||||
@@ -124,10 +154,17 @@ class Routes {
|
||||
// Newsletter controller
|
||||
NewsletterController::register_routes();
|
||||
|
||||
// Campaigns controller
|
||||
CampaignsController::register_routes();
|
||||
|
||||
// Modules controller
|
||||
$modules_controller = new ModulesController();
|
||||
$modules_controller->register_routes();
|
||||
|
||||
// Module Settings controller
|
||||
$module_settings_controller = new ModuleSettingsController();
|
||||
$module_settings_controller->register_routes();
|
||||
|
||||
// Frontend controllers (customer-facing)
|
||||
ShopController::register_routes();
|
||||
FrontendCartController::register_routes();
|
||||
|
||||
@@ -21,7 +21,6 @@ class CustomerSettingsProvider {
|
||||
// General
|
||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
||||
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
||||
'wishlist_enabled' => get_option('woonoow_wishlist_enabled', 'yes') === 'yes',
|
||||
|
||||
// VIP Customer Qualification
|
||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||
@@ -50,10 +49,7 @@ class CustomerSettingsProvider {
|
||||
update_option('woonoow_multiple_addresses_enabled', $value);
|
||||
}
|
||||
|
||||
if (array_key_exists('wishlist_enabled', $settings)) {
|
||||
$value = !empty($settings['wishlist_enabled']) ? 'yes' : 'no';
|
||||
update_option('woonoow_wishlist_enabled', $value);
|
||||
}
|
||||
|
||||
|
||||
// VIP settings
|
||||
if (isset($settings['vip_min_spent'])) {
|
||||
|
||||
@@ -109,10 +109,10 @@ class NavigationRegistry {
|
||||
[
|
||||
'key' => 'dashboard',
|
||||
'label' => __('Dashboard', 'woonoow'),
|
||||
'path' => '/',
|
||||
'path' => '/dashboard',
|
||||
'icon' => 'layout-dashboard',
|
||||
'children' => [
|
||||
['label' => __('Overview', 'woonoow'), 'mode' => 'spa', 'path' => '/', 'exact' => true],
|
||||
['label' => __('Overview', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard', 'exact' => true],
|
||||
['label' => __('Revenue', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/revenue'],
|
||||
['label' => __('Orders', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/orders'],
|
||||
['label' => __('Products', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/products'],
|
||||
@@ -127,7 +127,7 @@ class NavigationRegistry {
|
||||
'path' => '/orders',
|
||||
'icon' => 'receipt-text',
|
||||
'children' => [
|
||||
['label' => __('All orders', 'woonoow'), 'mode' => 'spa', 'path' => '/orders'],
|
||||
['label' => __('All orders', 'woonoow'), 'mode' => 'spa', 'path' => '/orders', 'exact' => true],
|
||||
['label' => __('New', 'woonoow'), 'mode' => 'spa', 'path' => '/orders/new'],
|
||||
// Future: Drafts, Recurring, etc.
|
||||
],
|
||||
@@ -138,7 +138,7 @@ class NavigationRegistry {
|
||||
'path' => '/products',
|
||||
'icon' => 'package',
|
||||
'children' => [
|
||||
['label' => __('All products', 'woonoow'), 'mode' => 'spa', 'path' => '/products'],
|
||||
['label' => __('All products', 'woonoow'), 'mode' => 'spa', 'path' => '/products', 'exact' => true],
|
||||
['label' => __('New', 'woonoow'), 'mode' => 'spa', 'path' => '/products/new'],
|
||||
['label' => __('Categories', 'woonoow'), 'mode' => 'spa', 'path' => '/products/categories'],
|
||||
['label' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'],
|
||||
@@ -151,7 +151,7 @@ class NavigationRegistry {
|
||||
'path' => '/customers',
|
||||
'icon' => 'users',
|
||||
'children' => [
|
||||
['label' => __('All customers', 'woonoow'), 'mode' => 'spa', 'path' => '/customers'],
|
||||
['label' => __('All customers', 'woonoow'), 'mode' => 'spa', 'path' => '/customers', 'exact' => true],
|
||||
['label' => __('New', 'woonoow'), 'mode' => 'spa', 'path' => '/customers/new'],
|
||||
],
|
||||
],
|
||||
@@ -260,10 +260,12 @@ class NavigationRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the navigation cache
|
||||
* Flush navigation cache
|
||||
*/
|
||||
public static function flush() {
|
||||
delete_option(self::NAV_OPTION);
|
||||
// Rebuild immediately after flush
|
||||
self::build_nav_tree();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ use WooNooW\Core\DataStores\OrderStore;
|
||||
use WooNooW\Core\MediaUpload;
|
||||
use WooNooW\Core\Notifications\PushNotificationHandler;
|
||||
use WooNooW\Core\Notifications\EmailManager;
|
||||
use WooNooW\Core\Campaigns\CampaignManager;
|
||||
use WooNooW\Core\ActivityLog\ActivityLogTable;
|
||||
use WooNooW\Branding;
|
||||
use WooNooW\Frontend\Assets as FrontendAssets;
|
||||
@@ -40,10 +41,11 @@ class Bootstrap {
|
||||
MediaUpload::init();
|
||||
PushNotificationHandler::init();
|
||||
EmailManager::instance(); // Initialize custom email system
|
||||
CampaignManager::init(); // Initialize campaigns CPT
|
||||
|
||||
// Frontend (customer-spa)
|
||||
FrontendAssets::init();
|
||||
Shortcodes::init();
|
||||
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
||||
TemplateOverride::init();
|
||||
new PageAppearance();
|
||||
|
||||
@@ -66,5 +68,64 @@ class Bootstrap {
|
||||
MailQueue::init();
|
||||
WooEmailOverride::init();
|
||||
OrderStore::init();
|
||||
|
||||
// Initialize cart for REST API requests
|
||||
add_action('woocommerce_init', [self::class, 'init_cart_for_rest_api']);
|
||||
|
||||
// Load custom variation attributes for WooCommerce admin
|
||||
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly initialize WooCommerce cart for REST API requests
|
||||
* This is the recommended approach per WooCommerce core team
|
||||
*/
|
||||
public static function init_cart_for_rest_api() {
|
||||
// Only load cart for REST API requests
|
||||
if (!WC()->is_rest_api_request()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load frontend includes (required for cart)
|
||||
WC()->frontend_includes();
|
||||
|
||||
// Load cart using WooCommerce's official method
|
||||
if (null === WC()->cart && function_exists('wc_load_cart')) {
|
||||
wc_load_cart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load custom variation attributes from post meta for WooCommerce admin
|
||||
* This ensures WooCommerce's native admin displays custom attributes correctly
|
||||
*/
|
||||
public static function load_variation_attributes($variation) {
|
||||
if (!$variation instanceof \WC_Product_Variation) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = wc_get_product($variation->get_parent_id());
|
||||
if (!$parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
foreach ($parent->get_attributes() as $attr_name => $attribute) {
|
||||
if (!$attribute->get_variation()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read from post meta (stored as lowercase)
|
||||
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||
$value = get_post_meta($variation->get_id(), $meta_key, true);
|
||||
|
||||
if (!empty($value)) {
|
||||
$attributes[strtolower($attr_name)] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($attributes)) {
|
||||
$variation->set_attributes($attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
479
includes/Core/Campaigns/CampaignManager.php
Normal file
479
includes/Core/Campaigns/CampaignManager.php
Normal file
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
/**
|
||||
* Campaign Manager
|
||||
*
|
||||
* Manages newsletter campaign CRUD operations and sending
|
||||
*
|
||||
* @package WooNooW\Core\Campaigns
|
||||
*/
|
||||
|
||||
namespace WooNooW\Core\Campaigns;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
class CampaignManager {
|
||||
|
||||
const POST_TYPE = 'wnw_campaign';
|
||||
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get instance
|
||||
*/
|
||||
public static function instance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
public static function init() {
|
||||
add_action('init', [__CLASS__, 'register_post_type']);
|
||||
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register campaign post type
|
||||
*/
|
||||
public static function register_post_type() {
|
||||
register_post_type(self::POST_TYPE, [
|
||||
'labels' => [
|
||||
'name' => __('Campaigns', 'woonoow'),
|
||||
'singular_name' => __('Campaign', 'woonoow'),
|
||||
],
|
||||
'public' => false,
|
||||
'show_ui' => false,
|
||||
'show_in_rest' => false,
|
||||
'supports' => ['title'],
|
||||
'capability_type' => 'post',
|
||||
'map_meta_cap' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new campaign
|
||||
*
|
||||
* @param array $data Campaign data
|
||||
* @return int|WP_Error Campaign ID or error
|
||||
*/
|
||||
public static function create($data) {
|
||||
$post_data = [
|
||||
'post_type' => self::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
|
||||
];
|
||||
|
||||
$campaign_id = wp_insert_post($post_data, true);
|
||||
|
||||
if (is_wp_error($campaign_id)) {
|
||||
return $campaign_id;
|
||||
}
|
||||
|
||||
// Save meta fields
|
||||
self::update_meta($campaign_id, $data);
|
||||
|
||||
return $campaign_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update campaign
|
||||
*
|
||||
* @param int $campaign_id Campaign ID
|
||||
* @param array $data Campaign data
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public static function update($campaign_id, $data) {
|
||||
$post = get_post($campaign_id);
|
||||
|
||||
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
|
||||
}
|
||||
|
||||
// Update title if provided
|
||||
if (isset($data['title'])) {
|
||||
wp_update_post([
|
||||
'ID' => $campaign_id,
|
||||
'post_title' => sanitize_text_field($data['title']),
|
||||
]);
|
||||
}
|
||||
|
||||
// Update meta fields
|
||||
self::update_meta($campaign_id, $data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update campaign meta
|
||||
*
|
||||
* @param int $campaign_id
|
||||
* @param array $data
|
||||
*/
|
||||
private static function update_meta($campaign_id, $data) {
|
||||
$meta_fields = [
|
||||
'subject' => '_wnw_subject',
|
||||
'content' => '_wnw_content',
|
||||
'status' => '_wnw_status',
|
||||
'scheduled_at' => '_wnw_scheduled_at',
|
||||
];
|
||||
|
||||
foreach ($meta_fields as $key => $meta_key) {
|
||||
if (isset($data[$key])) {
|
||||
$value = $data[$key];
|
||||
|
||||
// Sanitize based on field type
|
||||
if ($key === 'content') {
|
||||
$value = wp_kses_post($value);
|
||||
} elseif ($key === 'scheduled_at') {
|
||||
$value = sanitize_text_field($value);
|
||||
} elseif ($key === 'status') {
|
||||
$allowed = ['draft', 'scheduled', 'sending', 'sent', 'failed'];
|
||||
$value = in_array($value, $allowed) ? $value : 'draft';
|
||||
} else {
|
||||
$value = sanitize_text_field($value);
|
||||
}
|
||||
|
||||
update_post_meta($campaign_id, $meta_key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// Set default status if not provided
|
||||
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
|
||||
update_post_meta($campaign_id, '_wnw_status', 'draft');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign by ID
|
||||
*
|
||||
* @param int $campaign_id
|
||||
* @return array|null
|
||||
*/
|
||||
public static function get($campaign_id) {
|
||||
$post = get_post($campaign_id);
|
||||
|
||||
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::format_campaign($post);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all campaigns
|
||||
*
|
||||
* @param array $args Query args
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all($args = []) {
|
||||
$defaults = [
|
||||
'post_type' => self::POST_TYPE,
|
||||
'post_status' => 'any',
|
||||
'posts_per_page' => -1,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
|
||||
$query_args = wp_parse_args($args, $defaults);
|
||||
$query_args['post_type'] = self::POST_TYPE; // Force post type
|
||||
|
||||
$posts = get_posts($query_args);
|
||||
|
||||
return array_map([__CLASS__, 'format_campaign'], $posts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format campaign post to array
|
||||
*
|
||||
* @param WP_Post $post
|
||||
* @return array
|
||||
*/
|
||||
private static function format_campaign($post) {
|
||||
return [
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'subject' => get_post_meta($post->ID, '_wnw_subject', true),
|
||||
'content' => get_post_meta($post->ID, '_wnw_content', true),
|
||||
'status' => get_post_meta($post->ID, '_wnw_status', true) ?: 'draft',
|
||||
'scheduled_at' => get_post_meta($post->ID, '_wnw_scheduled_at', true),
|
||||
'sent_at' => get_post_meta($post->ID, '_wnw_sent_at', true),
|
||||
'recipient_count' => (int) get_post_meta($post->ID, '_wnw_recipient_count', true),
|
||||
'sent_count' => (int) get_post_meta($post->ID, '_wnw_sent_count', true),
|
||||
'failed_count' => (int) get_post_meta($post->ID, '_wnw_failed_count', true),
|
||||
'created_at' => $post->post_date,
|
||||
'updated_at' => $post->post_modified,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete campaign
|
||||
*
|
||||
* @param int $campaign_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function delete($campaign_id) {
|
||||
$post = get_post($campaign_id);
|
||||
|
||||
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return wp_delete_post($campaign_id, true) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send campaign
|
||||
*
|
||||
* @param int $campaign_id
|
||||
* @return array Result with sent/failed counts
|
||||
*/
|
||||
public static function send($campaign_id) {
|
||||
$campaign = self::get($campaign_id);
|
||||
|
||||
if (!$campaign) {
|
||||
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
|
||||
}
|
||||
|
||||
if ($campaign['status'] === 'sent') {
|
||||
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
|
||||
}
|
||||
|
||||
// Get subscribers
|
||||
$subscribers = self::get_subscribers();
|
||||
|
||||
if (empty($subscribers)) {
|
||||
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
|
||||
}
|
||||
|
||||
// Update status to sending
|
||||
update_post_meta($campaign_id, '_wnw_status', 'sending');
|
||||
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
// Get email template
|
||||
$template = self::render_campaign_email($campaign);
|
||||
|
||||
// Send in batches
|
||||
$batch_size = 50;
|
||||
$batches = array_chunk($subscribers, $batch_size);
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
foreach ($batch as $subscriber) {
|
||||
$email = $subscriber['email'];
|
||||
|
||||
// Replace subscriber-specific variables
|
||||
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
||||
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
|
||||
|
||||
// Send email
|
||||
$result = wp_mail(
|
||||
$email,
|
||||
$template['subject'],
|
||||
$body,
|
||||
['Content-Type: text/html; charset=UTF-8']
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
$sent++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between batches
|
||||
if (count($batches) > 1) {
|
||||
sleep(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Update campaign stats
|
||||
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
|
||||
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
|
||||
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
|
||||
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'sent' => $sent,
|
||||
'failed' => $failed,
|
||||
'total' => count($subscribers),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email
|
||||
*
|
||||
* @param int $campaign_id
|
||||
* @param string $email Test email address
|
||||
* @return bool
|
||||
*/
|
||||
public static function send_test($campaign_id, $email) {
|
||||
$campaign = self::get($campaign_id);
|
||||
|
||||
if (!$campaign) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$template = self::render_campaign_email($campaign);
|
||||
|
||||
// Replace subscriber-specific variables
|
||||
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
||||
$body = str_replace('{unsubscribe_url}', '#', $body);
|
||||
|
||||
return wp_mail(
|
||||
$email,
|
||||
'[TEST] ' . $template['subject'],
|
||||
$body,
|
||||
['Content-Type: text/html; charset=UTF-8']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render campaign email using EmailRenderer
|
||||
*
|
||||
* @param array $campaign
|
||||
* @return array ['subject' => string, 'body' => string]
|
||||
*/
|
||||
private static function render_campaign_email($campaign) {
|
||||
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||
|
||||
// Get the campaign email template
|
||||
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
||||
|
||||
// Fallback if no template configured
|
||||
if (!$template) {
|
||||
$subject = $campaign['subject'] ?: $campaign['title'];
|
||||
$body = $campaign['content'];
|
||||
} else {
|
||||
$subject = $template['subject'] ?: $campaign['subject'];
|
||||
|
||||
// Replace {content} with campaign content
|
||||
$body = str_replace('{content}', $campaign['content'], $template['body']);
|
||||
|
||||
// Replace {campaign_title}
|
||||
$body = str_replace('{campaign_title}', $campaign['title'], $body);
|
||||
}
|
||||
|
||||
// Replace common variables
|
||||
$site_name = get_bloginfo('name');
|
||||
$site_url = home_url();
|
||||
|
||||
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
|
||||
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
|
||||
$body = str_replace('{site_url}', $site_url, $body);
|
||||
$body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body);
|
||||
$body = str_replace('{current_year}', date('Y'), $body);
|
||||
|
||||
// Render through email design template
|
||||
$design_path = $renderer->get_design_template();
|
||||
if (file_exists($design_path)) {
|
||||
$body = $renderer->render_html($design_path, $body, $subject, [
|
||||
'site_name' => $site_name,
|
||||
'site_url' => $site_url,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscribers
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_subscribers() {
|
||||
// Check if using custom table
|
||||
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
|
||||
|
||||
if ($use_table && self::has_subscribers_table()) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_subscribers';
|
||||
return $wpdb->get_results(
|
||||
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
// Use wp_options storage
|
||||
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
||||
return array_filter($subscribers, function($sub) {
|
||||
return ($sub['status'] ?? 'active') === 'active';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscribers table exists
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function has_subscribers_table() {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_subscribers';
|
||||
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unsubscribe URL
|
||||
*
|
||||
* @param string $email
|
||||
* @return string
|
||||
*/
|
||||
private static function get_unsubscribe_url($email) {
|
||||
// Use NewsletterController's secure token-based URL
|
||||
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process scheduled campaigns (WP-Cron)
|
||||
*/
|
||||
public static function process_scheduled_campaigns() {
|
||||
// Only if scheduling is enabled
|
||||
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaigns = self::get_all([
|
||||
'meta_query' => [
|
||||
[
|
||||
'key' => '_wnw_status',
|
||||
'value' => 'scheduled',
|
||||
],
|
||||
[
|
||||
'key' => '_wnw_scheduled_at',
|
||||
'value' => current_time('mysql'),
|
||||
'compare' => '<=',
|
||||
'type' => 'DATETIME',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
foreach ($campaigns as $campaign) {
|
||||
self::send($campaign['id']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable scheduling (registers cron)
|
||||
*/
|
||||
public static function enable_scheduling() {
|
||||
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable scheduling (clears cron)
|
||||
*/
|
||||
public static function disable_scheduling() {
|
||||
wp_clear_scheduled_hook(self::CRON_HOOK);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ namespace WooNooW\Core\Mail;
|
||||
class MailQueue {
|
||||
public static function init() {
|
||||
add_action('woonoow/mail/send', [__CLASS__, 'sendNow'], 10, 1);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW MailQueue] Hook registered: woonoow/mail/send -> MailQueue::sendNow');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,10 +21,6 @@ class MailQueue {
|
||||
// Store payload in wp_options (temporary, will be deleted after sending)
|
||||
update_option($email_id, $payload, false); // false = don't autoload
|
||||
|
||||
// Debug log in dev mode
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW MailQueue] Queued email ID: ' . $email_id . ' to: ' . ($payload['to'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
if (function_exists('as_enqueue_async_action')) {
|
||||
// Use Action Scheduler - pass email_id as single argument
|
||||
@@ -45,49 +37,28 @@ class MailQueue {
|
||||
* Retrieves payload from wp_options and deletes it after sending.
|
||||
*/
|
||||
public static function sendNow($email_id = null) {
|
||||
error_log('[WooNooW MailQueue] sendNow() called with args: ' . print_r(func_get_args(), true));
|
||||
error_log('[WooNooW MailQueue] email_id type: ' . gettype($email_id));
|
||||
error_log('[WooNooW MailQueue] email_id value: ' . var_export($email_id, true));
|
||||
|
||||
// Action Scheduler might pass an array, extract the first element
|
||||
if (is_array($email_id)) {
|
||||
error_log('[WooNooW MailQueue] email_id is array, extracting first element');
|
||||
$email_id = $email_id[0] ?? null;
|
||||
}
|
||||
|
||||
// email_id should be a string
|
||||
if (empty($email_id)) {
|
||||
error_log('[WooNooW MailQueue] ERROR: No email_id provided after extraction. Received: ' . print_r(func_get_args(), true));
|
||||
return;
|
||||
}
|
||||
|
||||
error_log('[WooNooW MailQueue] Processing email_id: ' . $email_id);
|
||||
|
||||
// Retrieve payload from wp_options
|
||||
$p = get_option($email_id);
|
||||
|
||||
if (!$p) {
|
||||
error_log('[WooNooW MailQueue] ERROR: Email payload not found for ID: ' . $email_id);
|
||||
error_log('[WooNooW MailQueue] Checking if option exists in database...');
|
||||
global $wpdb;
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
|
||||
$email_id
|
||||
));
|
||||
error_log('[WooNooW MailQueue] Option exists in DB: ' . ($exists ? 'yes' : 'no'));
|
||||
return;
|
||||
}
|
||||
|
||||
error_log('[WooNooW MailQueue] Payload retrieved - To: ' . ($p['to'] ?? 'unknown') . ', Subject: ' . ($p['subject'] ?? 'unknown'));
|
||||
|
||||
// Temporarily disable WooEmailOverride to prevent infinite loop
|
||||
if (class_exists('WooNooW\Core\Mail\WooEmailOverride')) {
|
||||
error_log('[WooNooW MailQueue] Disabling WooEmailOverride to prevent loop');
|
||||
WooEmailOverride::disable();
|
||||
}
|
||||
|
||||
error_log('[WooNooW MailQueue] Calling wp_mail() now...');
|
||||
|
||||
$result = wp_mail(
|
||||
$p['to'] ?? '',
|
||||
$p['subject'] ?? '',
|
||||
@@ -96,17 +67,12 @@ class MailQueue {
|
||||
$p['attachments'] ?? []
|
||||
);
|
||||
|
||||
error_log('[WooNooW MailQueue] wp_mail() returned: ' . ($result ? 'TRUE (success)' : 'FALSE (failed)'));
|
||||
|
||||
// Re-enable
|
||||
if (class_exists('WooNooW\Core\Mail\WooEmailOverride')) {
|
||||
error_log('[WooNooW MailQueue] Re-enabling WooEmailOverride');
|
||||
WooEmailOverride::enable();
|
||||
}
|
||||
|
||||
// Delete the temporary option after sending
|
||||
delete_option($email_id);
|
||||
|
||||
error_log('[WooNooW MailQueue] Sent and deleted email ID: ' . $email_id . ' to: ' . ($p['to'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ namespace WooNooW\Core;
|
||||
class ModuleRegistry {
|
||||
|
||||
/**
|
||||
* Get all registered modules
|
||||
* Get built-in modules
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all_modules() {
|
||||
private static function get_builtin_modules() {
|
||||
$modules = [
|
||||
'newsletter' => [
|
||||
'id' => 'newsletter',
|
||||
@@ -26,6 +26,7 @@ class ModuleRegistry {
|
||||
'category' => 'marketing',
|
||||
'icon' => 'mail',
|
||||
'default_enabled' => true,
|
||||
'has_settings' => true,
|
||||
'features' => [
|
||||
__('Subscriber management', 'woonoow'),
|
||||
__('Email campaigns', 'woonoow'),
|
||||
@@ -39,6 +40,7 @@ class ModuleRegistry {
|
||||
'category' => 'customers',
|
||||
'icon' => 'heart',
|
||||
'default_enabled' => true,
|
||||
'has_settings' => true,
|
||||
'features' => [
|
||||
__('Save products to wishlist', 'woonoow'),
|
||||
__('Wishlist page', 'woonoow'),
|
||||
@@ -89,7 +91,118 @@ class ModuleRegistry {
|
||||
],
|
||||
];
|
||||
|
||||
return apply_filters('woonoow/modules/registry', $modules);
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get addon modules from AddonRegistry
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_addon_modules() {
|
||||
$addons = apply_filters('woonoow/addon_registry', []);
|
||||
$modules = [];
|
||||
|
||||
foreach ($addons as $addon_id => $addon) {
|
||||
$modules[$addon_id] = [
|
||||
'id' => $addon_id,
|
||||
'label' => $addon['name'] ?? ucfirst($addon_id),
|
||||
'description' => $addon['description'] ?? '',
|
||||
'category' => $addon['category'] ?? 'other',
|
||||
'icon' => $addon['icon'] ?? 'puzzle',
|
||||
'default_enabled' => false,
|
||||
'features' => $addon['features'] ?? [],
|
||||
'is_addon' => true,
|
||||
'version' => $addon['version'] ?? '1.0.0',
|
||||
'author' => $addon['author'] ?? '',
|
||||
'has_settings' => !empty($addon['has_settings']),
|
||||
'settings_component' => $addon['settings_component'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all modules (built-in + addons)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all_modules() {
|
||||
$builtin = self::get_builtin_modules();
|
||||
$addons = self::get_addon_modules();
|
||||
|
||||
return array_merge($builtin, $addons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories dynamically from registered modules
|
||||
*
|
||||
* @return array Associative array of category_id => label
|
||||
*/
|
||||
public static function get_categories() {
|
||||
$all_modules = self::get_all_modules();
|
||||
$categories = [];
|
||||
|
||||
// Extract unique categories from modules
|
||||
foreach ($all_modules as $module) {
|
||||
$cat = $module['category'] ?? 'other';
|
||||
if (!isset($categories[$cat])) {
|
||||
$categories[$cat] = self::get_category_label($cat);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by predefined order
|
||||
$order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
|
||||
uksort($categories, function($a, $b) use ($order) {
|
||||
$pos_a = array_search($a, $order);
|
||||
$pos_b = array_search($b, $order);
|
||||
if ($pos_a === false) $pos_a = 999;
|
||||
if ($pos_b === false) $pos_b = 999;
|
||||
return $pos_a - $pos_b;
|
||||
});
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for category
|
||||
*
|
||||
* @param string $category Category ID
|
||||
* @return string
|
||||
*/
|
||||
private static function get_category_label($category) {
|
||||
$labels = [
|
||||
'marketing' => __('Marketing & Sales', 'woonoow'),
|
||||
'customers' => __('Customer Experience', 'woonoow'),
|
||||
'products' => __('Products & Inventory', 'woonoow'),
|
||||
'shipping' => __('Shipping & Fulfillment', 'woonoow'),
|
||||
'payments' => __('Payments & Checkout', 'woonoow'),
|
||||
'analytics' => __('Analytics & Reports', 'woonoow'),
|
||||
'other' => __('Other Extensions', 'woonoow'),
|
||||
];
|
||||
|
||||
return $labels[$category] ?? ucfirst($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group modules by category
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_grouped_modules() {
|
||||
$all_modules = self::get_all_modules();
|
||||
$grouped = [];
|
||||
|
||||
foreach ($all_modules as $module) {
|
||||
$cat = $module['category'] ?? 'other';
|
||||
if (!isset($grouped[$cat])) {
|
||||
$grouped[$cat] = [];
|
||||
}
|
||||
$grouped[$cat][] = $module;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,6 +60,9 @@ class EmailManager {
|
||||
// New customer account
|
||||
add_action('woocommerce_created_customer', [$this, 'send_new_customer_email'], 10, 3);
|
||||
|
||||
// Password reset - intercept WordPress default email and use our template
|
||||
add_filter('retrieve_password_message', [$this, 'handle_password_reset_email'], 10, 4);
|
||||
|
||||
// Low stock / Out of stock
|
||||
add_action('woocommerce_low_stock', [$this, 'send_low_stock_email'], 10, 1);
|
||||
add_action('woocommerce_no_stock', [$this, 'send_out_of_stock_email'], 10, 1);
|
||||
@@ -304,6 +307,110 @@ class EmailManager {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle password reset email - intercept WordPress default and use our template
|
||||
*
|
||||
* @param string $message Email message (we replace this)
|
||||
* @param string $key Reset key
|
||||
* @param string $user_login User login
|
||||
* @param WP_User $user_data User object
|
||||
* @return string Empty string to prevent WordPress sending default email
|
||||
*/
|
||||
public function handle_password_reset_email($message, $key, $user_login, $user_data) {
|
||||
// Check if WooNooW notification system is enabled
|
||||
if (!self::is_enabled()) {
|
||||
return $message; // Use WordPress default
|
||||
}
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('password_reset', 'email', 'customer')) {
|
||||
return $message; // Use WordPress default
|
||||
}
|
||||
|
||||
// Build reset URL - use SPA page from appearance settings
|
||||
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
|
||||
if ($spa_page_id > 0) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
} else {
|
||||
// Fallback to home URL if SPA page not configured
|
||||
$spa_url = home_url('/');
|
||||
}
|
||||
|
||||
// Build SPA reset password URL with hash router format
|
||||
// Format: /store/#/reset-password?key=KEY&login=LOGIN
|
||||
$reset_link = rtrim($spa_url, '/') . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
||||
|
||||
// Create a pseudo WC_Customer for template rendering
|
||||
$customer = null;
|
||||
if (class_exists('WC_Customer')) {
|
||||
try {
|
||||
$customer = new \WC_Customer($user_data->ID);
|
||||
} catch (\Exception $e) {
|
||||
$customer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Send our custom email
|
||||
$this->send_password_reset_email($user_data, $key, $reset_link, $customer);
|
||||
|
||||
// Return empty string to prevent WordPress from sending its default plain-text email
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email using our template
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @param string $key Reset key
|
||||
* @param string $reset_link Full reset link URL
|
||||
* @param WC_Customer|null $customer WooCommerce customer object if available
|
||||
*/
|
||||
private function send_password_reset_email($user, $key, $reset_link, $customer = null) {
|
||||
// Get email renderer
|
||||
$renderer = EmailRenderer::instance();
|
||||
|
||||
// Build extra data for template variables
|
||||
$extra_data = [
|
||||
'reset_key' => $key,
|
||||
'reset_link' => $reset_link,
|
||||
'user_login' => $user->user_login,
|
||||
'user_email' => $user->user_email,
|
||||
'customer_name' => $user->display_name ?: $user->user_login,
|
||||
'customer_email' => $user->user_email,
|
||||
];
|
||||
|
||||
// Use WC_Customer if available for better template rendering
|
||||
$data = $customer ?: $user;
|
||||
|
||||
// Render email
|
||||
$email = $renderer->render('password_reset', 'customer', $data, $extra_data);
|
||||
|
||||
if (!$email) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Password reset email rendering failed for user: ' . $user->user_login);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email via wp_mail
|
||||
$headers = [
|
||||
'Content-Type: text/html; charset=UTF-8',
|
||||
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
|
||||
];
|
||||
|
||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Password reset email sent to ' . $email['to'] . ' - Result: ' . ($sent ? 'success' : 'failed'));
|
||||
}
|
||||
|
||||
// Log email sent
|
||||
do_action('woonoow_email_sent', 'password_reset', 'customer', $email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send low stock email
|
||||
*
|
||||
|
||||
@@ -140,9 +140,12 @@ class EmailRenderer {
|
||||
*/
|
||||
private function get_variables($event_id, $data, $extra_data = []) {
|
||||
$variables = [
|
||||
'site_name' => get_bloginfo('name'),
|
||||
'site_title' => get_bloginfo('name'),
|
||||
'store_name' => get_bloginfo('name'),
|
||||
'store_url' => home_url(),
|
||||
'site_title' => get_bloginfo('name'),
|
||||
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||
'support_email' => get_option('admin_email'),
|
||||
'current_year' => date('Y'),
|
||||
];
|
||||
@@ -249,7 +252,15 @@ class EmailRenderer {
|
||||
}
|
||||
|
||||
// Customer variables
|
||||
if ($data instanceof \WC_Customer) {
|
||||
if ($data instanceof \WC_Customer) {
|
||||
// Get temp password from user meta (stored during auto-registration)
|
||||
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
|
||||
|
||||
// Generate login URL (pointing to SPA login instead of wp-login)
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$login_url = $spa_page_id ? get_permalink($spa_page_id) . '#/login' : wp_login_url();
|
||||
|
||||
$variables = array_merge($variables, [
|
||||
'customer_id' => $data->get_id(),
|
||||
'customer_name' => $data->get_display_name(),
|
||||
@@ -257,6 +268,10 @@ class EmailRenderer {
|
||||
'customer_last_name' => $data->get_last_name(),
|
||||
'customer_email' => $data->get_email(),
|
||||
'customer_username' => $data->get_username(),
|
||||
'user_temp_password' => $user_temp_password ?: '',
|
||||
'login_url' => $login_url,
|
||||
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -273,8 +288,11 @@ class EmailRenderer {
|
||||
* @return string
|
||||
*/
|
||||
private function parse_cards($content) {
|
||||
// Match [card ...] ... [/card] patterns
|
||||
preg_match_all('/\[card([^\]]*)\](.*?)\[\/card\]/s', $content, $matches, PREG_SET_ORDER);
|
||||
// Use a single unified regex to match BOTH syntaxes in document order
|
||||
// This ensures cards are rendered in the order they appear
|
||||
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
|
||||
|
||||
preg_match_all($combined_pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
if (empty($matches)) {
|
||||
// No cards found, wrap entire content in a single card
|
||||
@@ -283,8 +301,19 @@ class EmailRenderer {
|
||||
|
||||
$html = '';
|
||||
foreach ($matches as $match) {
|
||||
$attributes = $this->parse_card_attributes($match[1]);
|
||||
$card_content = $match[2];
|
||||
// Determine which syntax was matched
|
||||
$full_match = $match[0][0];
|
||||
$new_syntax_type = !empty($match[1][0]) ? $match[1][0] : null; // [card:type] format
|
||||
$old_syntax_attrs = $match[2][0] ?? ''; // [card type="..."] format
|
||||
$card_content = $match[3][0];
|
||||
|
||||
if ($new_syntax_type) {
|
||||
// NEW syntax [card:type]
|
||||
$attributes = ['type' => $new_syntax_type];
|
||||
} else {
|
||||
// OLD syntax [card type="..."] or [card]
|
||||
$attributes = $this->parse_card_attributes($old_syntax_attrs);
|
||||
}
|
||||
|
||||
$html .= $this->render_card($card_content, $attributes);
|
||||
$html .= $this->render_card_spacing();
|
||||
@@ -337,10 +366,65 @@ class EmailRenderer {
|
||||
|
||||
// Get email customization settings for colors
|
||||
$email_settings = get_option('woonoow_email_settings', []);
|
||||
$primary_color = $email_settings['primary_color'] ?? '#7f54b3';
|
||||
$secondary_color = $email_settings['secondary_color'] ?? '#7f54b3';
|
||||
$button_text_color = $email_settings['button_text_color'] ?? '#ffffff';
|
||||
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
|
||||
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
|
||||
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
|
||||
|
||||
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
||||
// Helper function to generate button HTML
|
||||
$generateButtonHtml = function($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
|
||||
if ($style === 'outline') {
|
||||
// Outline button - transparent background with border
|
||||
$button_style = sprintf(
|
||||
'display: inline-block; background-color: transparent; color: %s; padding: 14px 28px; border: 2px solid %s; border-radius: 6px; text-decoration: none; font-weight: 600; font-family: "Inter", Arial, sans-serif; font-size: 16px; text-align: center; mso-padding-alt: 0;',
|
||||
esc_attr($secondary_color),
|
||||
esc_attr($secondary_color)
|
||||
);
|
||||
} else {
|
||||
// Solid button - full background color
|
||||
$button_style = sprintf(
|
||||
'display: inline-block; background-color: %s; color: %s; padding: 14px 28px; border: none; border-radius: 6px; text-decoration: none; font-weight: 600; font-family: "Inter", Arial, sans-serif; font-size: 16px; text-align: center; mso-padding-alt: 0;',
|
||||
esc_attr($primary_color),
|
||||
esc_attr($button_text_color)
|
||||
);
|
||||
}
|
||||
|
||||
// Use table-based button for better email client compatibility
|
||||
return sprintf(
|
||||
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
|
||||
esc_url($url),
|
||||
$button_style,
|
||||
esc_html($text)
|
||||
);
|
||||
};
|
||||
|
||||
// NEW FORMAT: [button:style](url)Text[/button]
|
||||
$content = preg_replace_callback(
|
||||
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||
function($matches) use ($generateButtonHtml) {
|
||||
$style = $matches[1]; // solid or outline
|
||||
$url = $matches[2];
|
||||
$text = trim($matches[3]);
|
||||
return $generateButtonHtml($url, $style, $text);
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
|
||||
$content = preg_replace_callback(
|
||||
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
|
||||
function($matches) use ($generateButtonHtml) {
|
||||
$url = $matches[1];
|
||||
$style = $matches[2] ?? 'solid';
|
||||
$text = trim($matches[3]);
|
||||
return $generateButtonHtml($url, $style, $text);
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
$class = 'card';
|
||||
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
|
||||
$content_style = 'padding: 32px 40px;';
|
||||
@@ -367,15 +451,15 @@ class EmailRenderer {
|
||||
}
|
||||
// Success card - green theme
|
||||
elseif ($type === 'success') {
|
||||
$style .= ' background-color: #f0fdf4; border-left: 4px solid #22c55e;';
|
||||
$style .= ' background-color: #f0fdf4;';
|
||||
}
|
||||
// Info card - blue theme
|
||||
elseif ($type === 'info') {
|
||||
$style .= ' background-color: #f0f7ff; border-left: 4px solid #0071e3;';
|
||||
$style .= ' background-color: #f0f7ff;';
|
||||
}
|
||||
// Warning card - orange theme
|
||||
// Warning card - orange/yellow theme
|
||||
elseif ($type === 'warning') {
|
||||
$style .= ' background-color: #fff8e1; border-left: 4px solid #ff9800;';
|
||||
$style .= ' background-color: #fff8e1;';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,8 +640,13 @@ class EmailRenderer {
|
||||
* @return string
|
||||
*/
|
||||
private function get_social_icon_url($platform, $color = 'white') {
|
||||
// Use local PNG icons
|
||||
$plugin_url = plugin_dir_url(dirname(dirname(dirname(__FILE__))));
|
||||
// Use plugin URL constant if available, otherwise calculate from file path
|
||||
if (defined('WOONOOW_URL')) {
|
||||
$plugin_url = WOONOOW_URL;
|
||||
} else {
|
||||
// File is at includes/Core/Notifications/EmailRenderer.php - need 4 levels up
|
||||
$plugin_url = plugin_dir_url(dirname(dirname(dirname(dirname(__FILE__)))));
|
||||
}
|
||||
$filename = sprintf('mage--%s-%s.png', $platform, $color);
|
||||
return $plugin_url . 'assets/icons/' . $filename;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ class EventRegistry {
|
||||
'wc_email' => 'customer_new_account',
|
||||
'enabled' => true,
|
||||
],
|
||||
'password_reset' => [
|
||||
'id' => 'password_reset',
|
||||
'label' => __('Password Reset', 'woonoow'),
|
||||
'description' => __('When a customer requests a password reset', 'woonoow'),
|
||||
'category' => 'customers',
|
||||
'recipient_type' => 'customer',
|
||||
'wc_email' => '',
|
||||
'enabled' => true,
|
||||
'variables' => [
|
||||
'{reset_link}' => __('Password reset link', 'woonoow'),
|
||||
'{reset_key}' => __('Password reset key', 'woonoow'),
|
||||
'{user_login}' => __('Username', 'woonoow'),
|
||||
'{user_email}' => __('User email', 'woonoow'),
|
||||
'{site_name}' => __('Site name', 'woonoow'),
|
||||
],
|
||||
],
|
||||
|
||||
// ===== NEWSLETTER EVENTS =====
|
||||
'newsletter_welcome' => [
|
||||
@@ -63,6 +79,21 @@ class EventRegistry {
|
||||
'wc_email' => '',
|
||||
'enabled' => true,
|
||||
],
|
||||
'newsletter_campaign' => [
|
||||
'id' => 'newsletter_campaign',
|
||||
'label' => __('Newsletter Campaign', 'woonoow'),
|
||||
'description' => __('Master email design template for newsletter campaigns', 'woonoow'),
|
||||
'category' => 'marketing',
|
||||
'recipient_type' => 'customer',
|
||||
'wc_email' => '',
|
||||
'enabled' => true,
|
||||
'variables' => [
|
||||
'{content}' => __('Campaign content', 'woonoow'),
|
||||
'{campaign_title}' => __('Campaign title', 'woonoow'),
|
||||
'{subscriber_email}' => __('Subscriber email', 'woonoow'),
|
||||
'{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'),
|
||||
],
|
||||
],
|
||||
|
||||
// ===== ORDER INITIATION =====
|
||||
'order_placed' => [
|
||||
@@ -340,4 +371,150 @@ class EventRegistry {
|
||||
public static function event_exists($event_id, $recipient_type) {
|
||||
return self::get_event($event_id, $recipient_type) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variables available for a specific event
|
||||
*
|
||||
* Returns both common variables and event-specific variables
|
||||
*
|
||||
* @param string $event_id Event ID
|
||||
* @param string $recipient_type Recipient type
|
||||
* @return array Array of variable definitions with key => description
|
||||
*/
|
||||
public static function get_variables_for_event($event_id, $recipient_type = 'customer') {
|
||||
// Common variables available for ALL events
|
||||
$common = [
|
||||
'{site_name}' => __('Store/Site name', 'woonoow'),
|
||||
'{site_title}' => __('Site title', 'woonoow'),
|
||||
'{store_url}' => __('Store URL', 'woonoow'),
|
||||
'{shop_url}' => __('Shop page URL', 'woonoow'),
|
||||
'{my_account_url}' => __('My Account page URL', 'woonoow'),
|
||||
'{login_url}' => __('Login page URL', 'woonoow'),
|
||||
'{support_email}' => __('Support email address', 'woonoow'),
|
||||
'{current_year}' => __('Current year', 'woonoow'),
|
||||
'{current_date}' => __('Current date', 'woonoow'),
|
||||
];
|
||||
|
||||
// Customer variables (for customer-facing events)
|
||||
$customer_vars = [
|
||||
'{customer_name}' => __('Customer full name', 'woonoow'),
|
||||
'{customer_first_name}' => __('Customer first name', 'woonoow'),
|
||||
'{customer_last_name}' => __('Customer last name', 'woonoow'),
|
||||
'{customer_email}' => __('Customer email', 'woonoow'),
|
||||
'{customer_phone}' => __('Customer phone', 'woonoow'),
|
||||
];
|
||||
|
||||
// Order variables (for order-related events)
|
||||
$order_vars = [
|
||||
'{order_id}' => __('Order ID/number', 'woonoow'),
|
||||
'{order_number}' => __('Order number', 'woonoow'),
|
||||
'{order_date}' => __('Order date', 'woonoow'),
|
||||
'{order_total}' => __('Order total', 'woonoow'),
|
||||
'{order_subtotal}' => __('Order subtotal', 'woonoow'),
|
||||
'{order_tax}' => __('Order tax', 'woonoow'),
|
||||
'{order_shipping}' => __('Shipping cost', 'woonoow'),
|
||||
'{order_discount}' => __('Discount amount', 'woonoow'),
|
||||
'{order_status}' => __('Order status', 'woonoow'),
|
||||
'{order_url}' => __('Order details URL', 'woonoow'),
|
||||
'{order_items_table}' => __('Order items table (HTML)', 'woonoow'),
|
||||
'{billing_address}' => __('Billing address', 'woonoow'),
|
||||
'{shipping_address}' => __('Shipping address', 'woonoow'),
|
||||
'{payment_method}' => __('Payment method', 'woonoow'),
|
||||
'{payment_status}' => __('Payment status', 'woonoow'),
|
||||
'{shipping_method}' => __('Shipping method', 'woonoow'),
|
||||
];
|
||||
|
||||
// Shipping/tracking variables (for shipped/delivered events)
|
||||
$shipping_vars = [
|
||||
'{tracking_number}' => __('Tracking number', 'woonoow'),
|
||||
'{tracking_url}' => __('Tracking URL', 'woonoow'),
|
||||
'{shipping_carrier}' => __('Shipping carrier', 'woonoow'),
|
||||
'{estimated_delivery}' => __('Estimated delivery date', 'woonoow'),
|
||||
];
|
||||
|
||||
// Product variables (for stock alerts)
|
||||
$product_vars = [
|
||||
'{product_name}' => __('Product name', 'woonoow'),
|
||||
'{product_sku}' => __('Product SKU', 'woonoow'),
|
||||
'{product_url}' => __('Product URL', 'woonoow'),
|
||||
'{product_price}' => __('Product price', 'woonoow'),
|
||||
'{stock_quantity}' => __('Stock quantity', 'woonoow'),
|
||||
];
|
||||
|
||||
// Newsletter variables
|
||||
$newsletter_vars = [
|
||||
'{subscriber_email}' => __('Subscriber email', 'woonoow'),
|
||||
'{subscriber_name}' => __('Subscriber name', 'woonoow'),
|
||||
'{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'),
|
||||
];
|
||||
|
||||
// Build variables based on event ID and category
|
||||
$event = self::get_event($event_id, $recipient_type);
|
||||
|
||||
// If event not found, try to match by just event_id
|
||||
if (!$event) {
|
||||
$all_events = self::get_all_events();
|
||||
foreach ($all_events as $e) {
|
||||
if ($e['id'] === $event_id) {
|
||||
$event = $e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start with common vars
|
||||
$variables = $common;
|
||||
|
||||
// Add category-specific vars
|
||||
if ($event) {
|
||||
$category = $event['category'] ?? '';
|
||||
|
||||
// Add customer vars for customer-facing events
|
||||
if (($event['recipient_type'] ?? '') === 'customer') {
|
||||
$variables = array_merge($variables, $customer_vars);
|
||||
}
|
||||
|
||||
// Add based on category
|
||||
switch ($category) {
|
||||
case 'orders':
|
||||
$variables = array_merge($variables, $order_vars);
|
||||
// Add tracking for completed/shipped events
|
||||
if (in_array($event_id, ['order_completed', 'order_shipped', 'order_delivered'])) {
|
||||
$variables = array_merge($variables, $shipping_vars);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'products':
|
||||
$variables = array_merge($variables, $product_vars);
|
||||
break;
|
||||
|
||||
case 'marketing':
|
||||
$variables = array_merge($variables, $newsletter_vars);
|
||||
// Add campaign-specific for newsletter_campaign
|
||||
if ($event_id === 'newsletter_campaign') {
|
||||
$variables['{content}'] = __('Campaign content', 'woonoow');
|
||||
$variables['{campaign_title}'] = __('Campaign title', 'woonoow');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'customers':
|
||||
$variables = array_merge($variables, $customer_vars);
|
||||
// Add account-specific vars
|
||||
if ($event_id === 'new_customer') {
|
||||
$variables['{user_temp_password}'] = __('Temporary password', 'woonoow');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add event-specific variables if defined
|
||||
if (!empty($event['variables'])) {
|
||||
$variables = array_merge($variables, $event['variables']);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically for easier browsing
|
||||
ksort($variables);
|
||||
|
||||
return $variables;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class DefaultTemplates
|
||||
'order_cancelled' => self::customer_order_cancelled(),
|
||||
'order_refunded' => self::customer_order_refunded(),
|
||||
'new_customer' => self::customer_new_customer(),
|
||||
'newsletter_campaign' => self::customer_newsletter_campaign(),
|
||||
],
|
||||
'staff' => [
|
||||
'order_placed' => self::staff_order_placed(),
|
||||
@@ -139,6 +140,7 @@ class DefaultTemplates
|
||||
'order_cancelled' => 'Order #{order_number} has been cancelled',
|
||||
'order_refunded' => 'Refund processed for order #{order_number}',
|
||||
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
|
||||
'newsletter_campaign' => '{campaign_title}',
|
||||
],
|
||||
'staff' => [
|
||||
'order_placed' => '[NEW ORDER] #{order_number} - ${order_total} from {customer_name}',
|
||||
@@ -194,18 +196,85 @@ Your account is ready. Here\'s what you can do now:
|
||||
✓ Easy returns and refunds
|
||||
[/card]
|
||||
|
||||
[button url="{my_account_url}"]Access Your Account[/button]
|
||||
[button url="{shop_url}"]Start Shopping[/button]
|
||||
[card type="success"]
|
||||
**Your Login Credentials:**
|
||||
|
||||
[card type="info"]
|
||||
💡 **Tip:** Check your account settings to receive personalized recommendations based on your interests.
|
||||
📧 **Email:** {customer_email}
|
||||
🔑 **Password:** {user_temp_password}
|
||||
|
||||
[button url="{login_url}" style="solid"]Log In Now[/button]
|
||||
|
||||
We recommend changing your password in Account Settings after logging in.
|
||||
[/card]
|
||||
|
||||
[button url="{shop_url}" style="outline"]Start Shopping[/button]
|
||||
|
||||
[card type="basic"]
|
||||
Got questions? Our customer service team is ready to help: {support_email}
|
||||
[/card]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer: Password Reset
|
||||
* Sent when customer requests a password reset
|
||||
*/
|
||||
private static function customer_password_reset()
|
||||
{
|
||||
return '[card type="hero"]
|
||||
## Reset Your Password 🔐
|
||||
|
||||
Hi {customer_name},
|
||||
|
||||
You\'ve requested to reset your password for your {site_name} account.
|
||||
[/card]
|
||||
|
||||
[card type="warning"]
|
||||
**Click the button below to reset your password:**
|
||||
|
||||
[button url="{reset_link}" style="solid"]Reset My Password[/button]
|
||||
|
||||
This link will expire in 24 hours for security reasons.
|
||||
[/card]
|
||||
|
||||
[card type="basic"]
|
||||
**Didn\'t request this?**
|
||||
|
||||
If you didn\'t request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
|
||||
For security, never share this link with anyone.
|
||||
[/card]
|
||||
|
||||
[card type="basic" bg="#f5f5f5"]
|
||||
If the button above doesn\'t work, copy and paste this link into your browser:
|
||||
|
||||
{reset_link}
|
||||
[/card]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer: Newsletter Campaign
|
||||
* Master design template for newsletter campaigns
|
||||
* The {content} variable is replaced with the actual campaign content
|
||||
*/
|
||||
private static function customer_newsletter_campaign()
|
||||
{
|
||||
return '[card type="hero"]
|
||||
## {campaign_title}
|
||||
[/card]
|
||||
|
||||
[card]
|
||||
{content}
|
||||
[/card]
|
||||
|
||||
[card type="basic" bg="#f5f5f5"]
|
||||
You are receiving this because you subscribed to {site_name} newsletter.
|
||||
|
||||
[Unsubscribe]({unsubscribe_url}) | [Visit Store]({site_url})
|
||||
|
||||
© {current_year} {site_name}. All rights reserved.
|
||||
[/card]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer: Order Placed
|
||||
* Sent immediately when customer places an order
|
||||
|
||||
@@ -15,6 +15,7 @@ class Assets {
|
||||
add_action('wp_head', [self::class, 'add_inline_config'], 5);
|
||||
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
||||
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
||||
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,9 +62,6 @@ class Assets {
|
||||
null,
|
||||
false // Load in header
|
||||
);
|
||||
|
||||
error_log('WooNooW Customer: Loading from Vite dev server at ' . $dev_server);
|
||||
error_log('WooNooW Customer: Scripts enqueued - vite client and main.tsx');
|
||||
} else {
|
||||
// Production mode: Load from build
|
||||
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
||||
@@ -71,55 +69,53 @@ class Assets {
|
||||
|
||||
// Check if build exists
|
||||
if (!file_exists($dist_path)) {
|
||||
error_log('WooNooW: customer-spa build not found. Run: cd customer-spa && npm run build');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load manifest to get hashed filenames
|
||||
$manifest_file = $dist_path . 'manifest.json';
|
||||
if (file_exists($manifest_file)) {
|
||||
$manifest = json_decode(file_get_contents($manifest_file), true);
|
||||
// Production build - load app.js and app.css directly
|
||||
$js_url = $plugin_url . 'customer-spa/dist/app.js';
|
||||
$css_url = $plugin_url . 'customer-spa/dist/app.css';
|
||||
|
||||
// Enqueue main JS
|
||||
if (isset($manifest['src/main.tsx'])) {
|
||||
$main_js = $manifest['src/main.tsx']['file'];
|
||||
wp_enqueue_script(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/' . $main_js,
|
||||
[],
|
||||
null,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'woonoow-customer-spa',
|
||||
$js_url,
|
||||
[],
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
// Add type="module" for Vite build
|
||||
add_filter('script_loader_tag', function($tag, $handle, $src) {
|
||||
if ($handle === 'woonoow-customer-spa') {
|
||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||
}
|
||||
return $tag;
|
||||
}, 10, 3);
|
||||
|
||||
// Enqueue main CSS
|
||||
if (isset($manifest['src/main.tsx']['css'])) {
|
||||
foreach ($manifest['src/main.tsx']['css'] as $css_file) {
|
||||
wp_enqueue_style(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/' . $css_file,
|
||||
[],
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for production build without manifest
|
||||
wp_enqueue_script(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/app.js',
|
||||
[],
|
||||
null,
|
||||
true
|
||||
);
|
||||
wp_enqueue_style(
|
||||
'woonoow-customer-spa',
|
||||
$css_url,
|
||||
[],
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/app.css',
|
||||
[],
|
||||
null
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Inject SPA mounting point for full mode
|
||||
*/
|
||||
public static function inject_spa_mount_point() {
|
||||
if (!self::should_load_assets()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in full mode and not on a page with shortcode
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
|
||||
if ($mode === 'full') {
|
||||
// Only inject if the mount point doesn't already exist (from shortcode)
|
||||
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +229,6 @@ class Assets {
|
||||
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
|
||||
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
|
||||
<?php
|
||||
error_log('WooNooW Customer: Scripts output directly in head with React Refresh preamble');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,21 +238,42 @@ class Assets {
|
||||
private static function should_load_assets() {
|
||||
global $post;
|
||||
|
||||
// First check: Is this a designated SPA page?
|
||||
if (self::is_spa_page()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get Customer SPA settings
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
|
||||
// If disabled, don't load
|
||||
if ($mode === 'disabled') {
|
||||
// Still check for shortcodes
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||
if (function_exists('is_shop') && is_shop()) {
|
||||
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||
if ($shop_page_id) {
|
||||
$shop_page = get_post($shop_page_id);
|
||||
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||
return true;
|
||||
}
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||
return true;
|
||||
|
||||
// Check for shortcodes on regular pages
|
||||
if ($post) {
|
||||
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_account')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -318,6 +334,27 @@ class Assets {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current page is the designated SPA page
|
||||
*/
|
||||
private static function is_spa_page() {
|
||||
global $post;
|
||||
if (!$post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get SPA page ID from appearance settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
||||
|
||||
// Check if current page matches the SPA page
|
||||
if ($spa_page_id && $post->ID == $spa_page_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dequeue conflicting scripts when SPA is active
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user