Compare commits
20 Commits
07020bc0dd
...
8093938e8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8093938e8b | ||
|
|
33e0f50238 | ||
|
|
ca3dd4aff3 | ||
|
|
70afb233cf | ||
|
|
8f61e39272 | ||
|
|
10acb58f6e | ||
|
|
e12c109270 | ||
|
|
4095d2a70c | ||
|
|
1c6b76efb4 | ||
|
|
9214172c79 | ||
|
|
e64045b0e1 | ||
|
|
0247f1edd8 | ||
|
|
c685c27b15 | ||
|
|
cc67288614 | ||
|
|
d575e12bf3 | ||
|
|
3aaee45981 | ||
|
|
863610043d | ||
|
|
9b8fa7d0f9 | ||
|
|
daebd5f989 | ||
|
|
c6cef97ef8 |
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!**
|
||||||
@@ -44,6 +44,7 @@ import { useActiveSection } from '@/hooks/useActiveSection';
|
|||||||
import { NAV_TREE_VERSION } from '@/nav/tree';
|
import { NAV_TREE_VERSION } from '@/nav/tree';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
|
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||||
|
|
||||||
function useFullscreen() {
|
function useFullscreen() {
|
||||||
const [on, setOn] = useState<boolean>(() => {
|
const [on, setOn] = useState<boolean>(() => {
|
||||||
@@ -98,15 +99,23 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
to={to}
|
to={to}
|
||||||
end={end}
|
end={end}
|
||||||
className={(nav) => {
|
className={(nav) => {
|
||||||
// Special case: Dashboard should also match root path "/"
|
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
||||||
const isDashboard = starts === '/dashboard' && location.pathname === '/';
|
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
||||||
|
|
||||||
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
||||||
const matchesChild = childPaths && Array.isArray(childPaths)
|
const matchesChild = childPaths && Array.isArray(childPaths)
|
||||||
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
||||||
: false;
|
: 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;
|
const mergedActive = nav.isActive || activeByPath;
|
||||||
if (typeof className === 'function') {
|
if (typeof className === 'function') {
|
||||||
// Preserve caller pattern: className receives { isActive }
|
// Preserve caller pattern: className receives { isActive }
|
||||||
@@ -123,6 +132,7 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
function Sidebar() {
|
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 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 active = "bg-secondary";
|
||||||
|
const { main } = useActiveSection();
|
||||||
|
|
||||||
// Icon mapping
|
// Icon mapping
|
||||||
const iconMap: Record<string, any> = {
|
const iconMap: Record<string, any> = {
|
||||||
@@ -144,19 +154,16 @@ function Sidebar() {
|
|||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{navTree.map((item: any) => {
|
{navTree.map((item: any) => {
|
||||||
const IconComponent = iconMap[item.icon] || Package;
|
const IconComponent = iconMap[item.icon] || Package;
|
||||||
// Extract child paths for matching
|
const isActive = main.key === item.key;
|
||||||
const childPaths = item.children?.map((child: any) => child.path).filter(Boolean) || [];
|
|
||||||
return (
|
return (
|
||||||
<ActiveNavLink
|
<Link
|
||||||
key={item.key}
|
key={item.key}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
startsWith={item.path}
|
className={`${link} ${isActive ? active : ''}`}
|
||||||
childPaths={childPaths}
|
|
||||||
className={({ isActive }: any) => `${link} ${isActive ? active : ''}`}
|
|
||||||
>
|
>
|
||||||
<IconComponent className="w-4 h-4" />
|
<IconComponent className="w-4 h-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</ActiveNavLink>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -168,6 +175,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 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 active = "bg-secondary";
|
||||||
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
||||||
|
const { main } = useActiveSection();
|
||||||
|
|
||||||
// Icon mapping (same as Sidebar)
|
// Icon mapping (same as Sidebar)
|
||||||
const iconMap: Record<string, any> = {
|
const iconMap: Record<string, any> = {
|
||||||
@@ -189,19 +197,16 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
|||||||
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
||||||
{navTree.map((item: any) => {
|
{navTree.map((item: any) => {
|
||||||
const IconComponent = iconMap[item.icon] || Package;
|
const IconComponent = iconMap[item.icon] || Package;
|
||||||
// Extract child paths for matching
|
const isActive = main.key === item.key;
|
||||||
const childPaths = item.children?.map((child: any) => child.path).filter(Boolean) || [];
|
|
||||||
return (
|
return (
|
||||||
<ActiveNavLink
|
<Link
|
||||||
key={item.key}
|
key={item.key}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
startsWith={item.path}
|
className={`${link} ${isActive ? active : ''}`}
|
||||||
childPaths={childPaths}
|
|
||||||
className={({ isActive }: any) => `${link} ${isActive ? active : ''}`}
|
|
||||||
>
|
>
|
||||||
<IconComponent className="w-4 h-4" />
|
<IconComponent className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium">{item.label}</span>
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
</ActiveNavLink>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -239,6 +244,7 @@ import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomizati
|
|||||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||||
import SettingsModules from '@/routes/Settings/Modules';
|
import SettingsModules from '@/routes/Settings/Modules';
|
||||||
|
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
||||||
import AppearanceIndex from '@/routes/Appearance';
|
import AppearanceIndex from '@/routes/Appearance';
|
||||||
import AppearanceGeneral from '@/routes/Appearance/General';
|
import AppearanceGeneral from '@/routes/Appearance/General';
|
||||||
import AppearanceHeader from '@/routes/Appearance/Header';
|
import AppearanceHeader from '@/routes/Appearance/Header';
|
||||||
@@ -553,6 +559,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||||
|
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||||
@@ -729,6 +736,11 @@ function AuthWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// Initialize Window API for addon developers
|
||||||
|
React.useEffect(() => {
|
||||||
|
initializeWindowAPI();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<HashRouter>
|
<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 />;
|
||||||
|
}
|
||||||
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">
|
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||||
{items.map((it) => {
|
{items.map((it) => {
|
||||||
const key = `${it.label}-${it.path || it.href}`;
|
const key = `${it.label}-${it.path || it.href}`;
|
||||||
// Check if current path starts with the submenu path (for sub-pages like /settings/notifications/staff)
|
// Determine active state based on exact pathname match
|
||||||
const isActive = !!it.path && (pathname === it.path || pathname.startsWith(it.path + '/'));
|
// Only ONE submenu item should be active at a time
|
||||||
|
const isActive = it.path === pathname;
|
||||||
|
|
||||||
const cls = [
|
const cls = [
|
||||||
'ui-ctrl inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap',
|
'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',
|
'focus:outline-none focus:ring-0 focus:shadow-none',
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export function useActiveSection(): { main: MainNode; all: MainNode[] } {
|
|||||||
if (settingsNode) return settingsNode;
|
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
|
// Try to find section by matching path prefix
|
||||||
for (const node of navTree) {
|
for (const node of navTree) {
|
||||||
if (node.path === '/') continue; // Skip dashboard for now
|
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'],
|
queryKey: ['modules-enabled'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/modules/enabled');
|
const response = await api.get('/modules/enabled');
|
||||||
return response.data;
|
return response || { enabled: [] };
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
});
|
});
|
||||||
|
|||||||
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() {
|
export default function AppearanceFooter() {
|
||||||
const { isEnabled } = useModules();
|
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [columns, setColumns] = useState('4');
|
const [columns, setColumns] = useState('4');
|
||||||
const [style, setStyle] = useState('detailed');
|
const [style, setStyle] = useState('detailed');
|
||||||
@@ -170,16 +170,17 @@ export default function AppearanceFooter() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/appearance/footer', {
|
const payload = {
|
||||||
columns,
|
columns,
|
||||||
style,
|
style,
|
||||||
copyright_text: copyrightText,
|
copyrightText,
|
||||||
elements,
|
elements,
|
||||||
social_links: socialLinks,
|
socialLinks,
|
||||||
sections,
|
sections,
|
||||||
contact_data: contactData,
|
contactData,
|
||||||
labels,
|
labels,
|
||||||
});
|
};
|
||||||
|
const response = await api.post('/appearance/footer', payload);
|
||||||
toast.success('Footer settings saved successfully');
|
toast.success('Footer settings saved successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save error:', error);
|
console.error('Save error:', error);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { api } from '@/lib/api';
|
|||||||
export default function AppearanceGeneral() {
|
export default function AppearanceGeneral() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
||||||
|
const [toastPosition, setToastPosition] = useState('top-right');
|
||||||
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
||||||
const [predefinedPair, setPredefinedPair] = useState('modern');
|
const [predefinedPair, setPredefinedPair] = useState('modern');
|
||||||
const [customHeading, setCustomHeading] = useState('');
|
const [customHeading, setCustomHeading] = useState('');
|
||||||
@@ -44,6 +45,7 @@ export default function AppearanceGeneral() {
|
|||||||
|
|
||||||
if (general) {
|
if (general) {
|
||||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||||
|
if (general.toast_position) setToastPosition(general.toast_position);
|
||||||
if (general.typography) {
|
if (general.typography) {
|
||||||
setTypographyMode(general.typography.mode || 'predefined');
|
setTypographyMode(general.typography.mode || 'predefined');
|
||||||
setPredefinedPair(general.typography.predefined_pair || 'modern');
|
setPredefinedPair(general.typography.predefined_pair || 'modern');
|
||||||
@@ -75,6 +77,7 @@ export default function AppearanceGeneral() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/appearance/general', {
|
await api.post('/appearance/general', {
|
||||||
spa_mode: spaMode,
|
spa_mode: spaMode,
|
||||||
|
toastPosition,
|
||||||
typography: {
|
typography: {
|
||||||
mode: typographyMode,
|
mode: typographyMode,
|
||||||
predefined_pair: typographyMode === 'predefined' ? predefinedPair : undefined,
|
predefined_pair: typographyMode === 'predefined' ? predefinedPair : undefined,
|
||||||
@@ -141,6 +144,31 @@ export default function AppearanceGeneral() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</SettingsCard>
|
</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 */}
|
{/* Typography */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Typography"
|
title="Typography"
|
||||||
|
|||||||
@@ -24,32 +24,14 @@ export default function NewsletterSubscribers() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isEnabled } = useModules();
|
const { isEnabled } = useModules();
|
||||||
|
|
||||||
if (!isEnabled('newsletter')) {
|
// Always call ALL hooks before any conditional returns
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: subscribersData, isLoading } = useQuery({
|
const { data: subscribersData, isLoading } = useQuery({
|
||||||
queryKey: ['newsletter-subscribers'],
|
queryKey: ['newsletter-subscribers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/newsletter/subscribers');
|
const response = await api.get('/newsletter/subscribers');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
enabled: isEnabled('newsletter'), // Only fetch when module is enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteSubscriber = useMutation({
|
const deleteSubscriber = useMutation({
|
||||||
@@ -88,6 +70,26 @@ export default function NewsletterSubscribers() {
|
|||||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
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 (
|
return (
|
||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
title="Newsletter Subscribers"
|
title="Newsletter Subscribers"
|
||||||
|
|||||||
@@ -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';
|
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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-semibold mb-3">{__('Product Attributes')}</h1>
|
<div className="flex items-center justify-between">
|
||||||
<p className="opacity-70">{__('Coming soon — SPA attributes manager.')}</p>
|
<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>
|
</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';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
term_id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
count: number;
|
||||||
|
parent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductCategories() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-semibold mb-3">{__('Product Categories')}</h1>
|
<div className="flex items-center justify-between">
|
||||||
<p className="opacity-70">{__('Coming soon — SPA categories manager.')}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface Tag {
|
||||||
|
term_id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductTags() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-semibold mb-3">{__('Product Tags')}</h1>
|
<div className="flex items-center justify-between">
|
||||||
<p className="opacity-70">{__('Coming soon — SPA tags manager.')}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { SettingsLayout } from './components/SettingsLayout';
|
import { SettingsLayout } from './components/SettingsLayout';
|
||||||
import { SettingsCard } from './components/SettingsCard';
|
import { SettingsCard } from './components/SettingsCard';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { toast } from 'sonner';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,19 +20,23 @@ interface Module {
|
|||||||
icon: string;
|
icon: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
is_addon?: boolean;
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
has_settings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModulesData {
|
interface ModulesData {
|
||||||
modules: Record<string, Module>;
|
modules: Record<string, Module>;
|
||||||
grouped: {
|
grouped: Record<string, Module[]>;
|
||||||
marketing: Module[];
|
categories: Record<string, string>;
|
||||||
customers: Module[];
|
|
||||||
products: Module[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modules() {
|
export default function Modules() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: modulesData, isLoading } = useQuery<ModulesData>({
|
const { data: modulesData, isLoading } = useQuery<ModulesData>({
|
||||||
queryKey: ['modules'],
|
queryKey: ['modules'],
|
||||||
@@ -64,21 +71,45 @@ export default function Modules() {
|
|||||||
users: Users,
|
users: Users,
|
||||||
'refresh-cw': RefreshCcw,
|
'refresh-cw': RefreshCcw,
|
||||||
key: Key,
|
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" />;
|
return <Icon className="h-5 w-5" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryLabel = (category: string) => {
|
// Filter modules based on search and category
|
||||||
const labels: Record<string, string> = {
|
const filteredGrouped = useMemo(() => {
|
||||||
marketing: __('Marketing & Sales'),
|
if (!modulesData?.grouped) return {};
|
||||||
customers: __('Customer Experience'),
|
|
||||||
products: __('Products & Inventory'),
|
const filtered: Record<string, Module[]> = {};
|
||||||
};
|
|
||||||
return labels[category] || category;
|
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 = ['marketing', 'customers', 'products'];
|
const categories = Object.keys(modulesData?.categories || {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
@@ -86,6 +117,41 @@ export default function Modules() {
|
|||||||
description={__('Enable or disable features to customize your store')}
|
description={__('Enable or disable features to customize your store')}
|
||||||
isLoading={isLoading}
|
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 */}
|
{/* 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="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">
|
<div className="text-sm space-y-2">
|
||||||
@@ -101,15 +167,21 @@ export default function Modules() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Module Categories */}
|
{/* Module Categories */}
|
||||||
{categories.map((category) => {
|
{Object.keys(filteredGrouped).length === 0 && (
|
||||||
const modules = modulesData?.grouped[category as keyof typeof modulesData.grouped] || [];
|
<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;
|
if (modules.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
key={category}
|
key={category}
|
||||||
title={getCategoryLabel(category)}
|
title={modulesData?.categories[category] || category}
|
||||||
description={__('Manage modules in this category')}
|
description={__('Manage modules in this category')}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -138,6 +210,11 @@ export default function Modules() {
|
|||||||
{__('Active')}
|
{__('Active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{module.is_addon && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{__('Addon')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
{module.description}
|
{module.description}
|
||||||
@@ -159,8 +236,21 @@ export default function Modules() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Switch */}
|
{/* Actions */}
|
||||||
<div className="flex items-center">
|
<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
|
<Switch
|
||||||
checked={module.enabled}
|
checked={module.enabled}
|
||||||
onCheckedChange={(enabled) =>
|
onCheckedChange={(enabled) =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Cart from './pages/Cart';
|
|||||||
import Checkout from './pages/Checkout';
|
import Checkout from './pages/Checkout';
|
||||||
import ThankYou from './pages/ThankYou';
|
import ThankYou from './pages/ThankYou';
|
||||||
import Account from './pages/Account';
|
import Account from './pages/Account';
|
||||||
|
import Wishlist from './pages/Wishlist';
|
||||||
|
|
||||||
// Create QueryClient instance
|
// Create QueryClient instance
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -45,8 +46,15 @@ const getThemeConfig = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get appearance settings from window
|
||||||
|
const getAppearanceSettings = () => {
|
||||||
|
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const themeConfig = getThemeConfig();
|
const themeConfig = getThemeConfig();
|
||||||
|
const appearanceSettings = getAppearanceSettings();
|
||||||
|
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -64,6 +72,9 @@ function App() {
|
|||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||||
|
|
||||||
|
{/* Wishlist - Public route accessible to guests */}
|
||||||
|
<Route path="/wishlist" element={<Wishlist />} />
|
||||||
|
|
||||||
{/* My Account */}
|
{/* My Account */}
|
||||||
<Route path="/my-account/*" element={<Account />} />
|
<Route path="/my-account/*" element={<Account />} />
|
||||||
|
|
||||||
@@ -73,8 +84,8 @@ function App() {
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
|
|
||||||
{/* Toast notifications */}
|
{/* Toast notifications - position from settings */}
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position={toastPosition} richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useModules } from '@/hooks/useModules';
|
|
||||||
|
|
||||||
interface NewsletterFormProps {
|
interface NewsletterFormProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -9,12 +8,6 @@ interface NewsletterFormProps {
|
|||||||
export function NewsletterForm({ description }: NewsletterFormProps) {
|
export function NewsletterForm({ description }: NewsletterFormProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
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'],
|
queryKey: ['modules-enabled'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/modules/enabled') as any;
|
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
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface WishlistItem {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
||||||
|
|
||||||
export function useWishlist() {
|
export function useWishlist() {
|
||||||
const [items, setItems] = useState<WishlistItem[]>([]);
|
const [items, setItems] = useState<WishlistItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -26,10 +28,36 @@ export function useWishlist() {
|
|||||||
const isEnabled = settings?.wishlist_enabled !== false;
|
const isEnabled = settings?.wishlist_enabled !== false;
|
||||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
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
|
// Load wishlist on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEnabled && isLoggedIn) {
|
if (isEnabled) {
|
||||||
loadWishlist();
|
if (isLoggedIn) {
|
||||||
|
loadWishlist();
|
||||||
|
} else {
|
||||||
|
loadGuestWishlist();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isEnabled, isLoggedIn]);
|
}, [isEnabled, isLoggedIn]);
|
||||||
|
|
||||||
@@ -49,11 +77,17 @@ export function useWishlist() {
|
|||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
const addToWishlist = useCallback(async (productId: number) => {
|
const addToWishlist = useCallback(async (productId: number) => {
|
||||||
|
// Guest mode: store in localStorage only
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
toast.error('Please login to add items to wishlist');
|
const newIds = new Set(productIds);
|
||||||
return false;
|
newIds.add(productId);
|
||||||
|
setProductIds(newIds);
|
||||||
|
saveGuestWishlist(newIds);
|
||||||
|
toast.success('Added to wishlist');
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logged in: use API
|
||||||
try {
|
try {
|
||||||
await api.post('/account/wishlist', { product_id: productId });
|
await api.post('/account/wishlist', { product_id: productId });
|
||||||
await loadWishlist(); // Reload to get full product details
|
await loadWishlist(); // Reload to get full product details
|
||||||
@@ -64,11 +98,20 @@ export function useWishlist() {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, loadWishlist]);
|
}, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
|
||||||
|
|
||||||
const removeFromWishlist = useCallback(async (productId: number) => {
|
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 {
|
try {
|
||||||
await api.delete(`/account/wishlist/${productId}`);
|
await api.delete(`/account/wishlist/${productId}`);
|
||||||
setItems(items.filter(item => item.product_id !== productId));
|
setItems(items.filter(item => item.product_id !== productId));
|
||||||
@@ -83,7 +126,7 @@ export function useWishlist() {
|
|||||||
toast.error('Failed to remove from wishlist');
|
toast.error('Failed to remove from wishlist');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, items]);
|
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
|
||||||
|
|
||||||
const toggleWishlist = useCallback(async (productId: number) => {
|
const toggleWishlist = useCallback(async (productId: number) => {
|
||||||
if (productIds.has(productId)) {
|
if (productIds.has(productId)) {
|
||||||
@@ -103,6 +146,7 @@ export function useWishlist() {
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
count: items.length,
|
count: items.length,
|
||||||
|
productIds,
|
||||||
addToWishlist,
|
addToWishlist,
|
||||||
removeFromWishlist,
|
removeFromWishlist,
|
||||||
toggleWishlist,
|
toggleWishlist,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSett
|
|||||||
import { SearchModal } from '../components/SearchModal';
|
import { SearchModal } from '../components/SearchModal';
|
||||||
import { NewsletterForm } from '../components/NewsletterForm';
|
import { NewsletterForm } from '../components/NewsletterForm';
|
||||||
import { LayoutWrapper } from './LayoutWrapper';
|
import { LayoutWrapper } from './LayoutWrapper';
|
||||||
|
import { useModules } from '../hooks/useModules';
|
||||||
|
import { useModuleSettings } from '../hooks/useModuleSettings';
|
||||||
|
|
||||||
interface BaseLayoutProps {
|
interface BaseLayoutProps {
|
||||||
children: ReactNode;
|
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 storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
const footerSettings = useFooterSettings();
|
const footerSettings = useFooterSettings();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
@@ -131,8 +135,8 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Wishlist */}
|
{/* Wishlist */}
|
||||||
{headerSettings.elements.wishlist && (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false && user?.isLoggedIn && (
|
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||||
<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">
|
<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" />
|
<Heart className="h-5 w-5" />
|
||||||
<span className="hidden lg:block">Wishlist</span>
|
<span className="hidden lg:block">Wishlist</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -258,20 +262,29 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className={`grid ${footerGridClass} gap-8`}>
|
<div className={`grid ${footerGridClass} gap-8`}>
|
||||||
{/* Render all sections dynamically */}
|
{/* Render all sections dynamically */}
|
||||||
{footerSettings.sections.filter((s: any) => s.visible).map((section: any) => (
|
{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}>
|
<div key={section.id}>
|
||||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||||
|
|
||||||
{/* Contact Section */}
|
{/* Contact Section */}
|
||||||
{section.type === 'contact' && (
|
{section.type === 'contact' && (
|
||||||
<div className="space-y-1 text-sm text-gray-600">
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
{footerSettings.contact_data.show_email && footerSettings.contact_data.email && (
|
{footerSettings.contact_data?.show_email && footerSettings.contact_data?.email && (
|
||||||
<p>Email: {footerSettings.contact_data.email}</p>
|
<p>Email: {footerSettings.contact_data.email}</p>
|
||||||
)}
|
)}
|
||||||
{footerSettings.contact_data.show_phone && footerSettings.contact_data.phone && (
|
{footerSettings.contact_data?.show_phone && footerSettings.contact_data?.phone && (
|
||||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||||
)}
|
)}
|
||||||
{footerSettings.contact_data.show_address && footerSettings.contact_data.address && (
|
{footerSettings.contact_data?.show_address && footerSettings.contact_data?.address && (
|
||||||
<p>{footerSettings.contact_data.address}</p>
|
<p>{footerSettings.contact_data.address}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +300,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Social Section */}
|
{/* Social Section */}
|
||||||
{section.type === 'social' && footerSettings.social_links.length > 0 && (
|
{section.type === 'social' && footerSettings.social_links?.length > 0 && (
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{footerSettings.social_links.map((link: any) => (
|
{footerSettings.social_links.map((link: any) => (
|
||||||
<li key={link.id}>
|
<li key={link.id}>
|
||||||
@@ -301,7 +314,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
|
|
||||||
{/* Newsletter Section */}
|
{/* Newsletter Section */}
|
||||||
{section.type === 'newsletter' && (
|
{section.type === 'newsletter' && (
|
||||||
<NewsletterForm description={footerSettings.labels.newsletter_description} />
|
<NewsletterForm description={footerSettings.labels?.newsletter_description} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom HTML Section */}
|
{/* Custom HTML Section */}
|
||||||
@@ -352,6 +365,8 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -413,6 +428,11 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{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 && (
|
{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">
|
<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})
|
<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 storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'BOUTIQUE';
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -539,6 +561,11 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
<User className="h-4 w-4" /> Account
|
<User className="h-4 w-4" /> Account
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
{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 && (
|
{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">
|
<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})
|
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||||
|
|
||||||
interface WishlistItem {
|
interface WishlistItem {
|
||||||
product_id: number;
|
product_id: number;
|
||||||
@@ -28,6 +29,7 @@ export default function Wishlist() {
|
|||||||
const [items, setItems] = useState<WishlistItem[]>([]);
|
const [items, setItems] = useState<WishlistItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||||
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
|
|
||||||
if (modulesLoading) {
|
if (modulesLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -217,19 +219,21 @@ export default function Wishlist() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Button
|
{(wishlistSettings.show_add_to_cart_button ?? true) && (
|
||||||
onClick={() => handleAddToCart(item)}
|
<Button
|
||||||
disabled={item.stock_status === 'outofstock'}
|
onClick={() => handleAddToCart(item)}
|
||||||
className="w-full"
|
disabled={item.stock_status === 'outofstock'}
|
||||||
size="sm"
|
className="w-full"
|
||||||
>
|
size="sm"
|
||||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
>
|
||||||
{item.stock_status === 'outofstock'
|
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||||
? 'Out of Stock'
|
{item.stock_status === 'outofstock'
|
||||||
: item.type === 'variable'
|
? 'Out of Stock'
|
||||||
? 'Select Options'
|
: item.type === 'variable'
|
||||||
: 'Add to Cart'}
|
? 'Select Options'
|
||||||
</Button>
|
: 'Add to Cart'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 } 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.
|
||||||
|
<a href="/wp-login.php" className="underline ml-1">Login</a> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -82,6 +82,7 @@ class AppearanceController {
|
|||||||
|
|
||||||
$general_data = [
|
$general_data = [
|
||||||
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
||||||
|
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
|
||||||
'typography' => [
|
'typography' => [
|
||||||
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
||||||
'predefined_pair' => sanitize_text_field($request->get_param('typography')['predefined_pair'] ?? 'modern'),
|
'predefined_pair' => sanitize_text_field($request->get_param('typography')['predefined_pair'] ?? 'modern'),
|
||||||
@@ -377,6 +378,7 @@ class AppearanceController {
|
|||||||
return [
|
return [
|
||||||
'general' => [
|
'general' => [
|
||||||
'spa_mode' => 'full',
|
'spa_mode' => 'full',
|
||||||
|
'toast_position' => 'top-right',
|
||||||
'typography' => [
|
'typography' => [
|
||||||
'mode' => 'predefined',
|
'mode' => 'predefined',
|
||||||
'predefined_pair' => 'modern',
|
'predefined_pair' => 'modern',
|
||||||
|
|||||||
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
|
||||||
|
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' => [$this, 'check_permission'],
|
||||||
|
'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) {
|
public function get_modules($request) {
|
||||||
$modules = ModuleRegistry::get_all_with_status();
|
$modules = ModuleRegistry::get_all_with_status();
|
||||||
|
$grouped = ModuleRegistry::get_grouped_modules();
|
||||||
|
|
||||||
// Group by category
|
// Add enabled status to grouped modules
|
||||||
$grouped = [
|
$enabled_modules = ModuleRegistry::get_enabled_modules();
|
||||||
'marketing' => [],
|
foreach ($grouped as $category => &$category_modules) {
|
||||||
'customers' => [],
|
foreach ($category_modules as &$module) {
|
||||||
'products' => [],
|
$module['enabled'] = in_array($module['id'], $enabled_modules);
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($modules as $module) {
|
|
||||||
$category = $module['category'];
|
|
||||||
if (isset($grouped[$category])) {
|
|
||||||
$grouped[$category][] = $module;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'modules' => $modules,
|
'modules' => $modules,
|
||||||
'grouped' => $grouped,
|
'grouped' => $grouped,
|
||||||
|
'categories' => ModuleRegistry::get_categories(),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +113,25 @@ class ModulesController extends WP_REST_Controller {
|
|||||||
$module_id = $request->get_param('module_id');
|
$module_id = $request->get_param('module_id');
|
||||||
$enabled = $request->get_param('enabled');
|
$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(
|
return new WP_Error(
|
||||||
'invalid_module',
|
'invalid_module',
|
||||||
__('Invalid module ID', 'woonoow'),
|
__('Invalid module ID', 'woonoow'),
|
||||||
@@ -127,28 +139,19 @@ class ModulesController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle module
|
||||||
if ($enabled) {
|
if ($enabled) {
|
||||||
$result = ModuleRegistry::enable($module_id);
|
ModuleRegistry::enable_module($module_id);
|
||||||
} else {
|
} else {
|
||||||
$result = ModuleRegistry::disable($module_id);
|
ModuleRegistry::disable_module($module_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result) {
|
// Return success response
|
||||||
return new WP_REST_Response([
|
return rest_ensure_response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => $enabled
|
'module_id' => $module_id,
|
||||||
? __('Module enabled successfully', 'woonoow')
|
'enabled' => $enabled,
|
||||||
: __('Module disabled successfully', 'woonoow'),
|
]);
|
||||||
'module_id' => $module_id,
|
|
||||||
'enabled' => $enabled,
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new WP_Error(
|
|
||||||
'toggle_failed',
|
|
||||||
__('Failed to toggle module', 'woonoow'),
|
|
||||||
['status' => 500]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -123,6 +123,69 @@ class ProductsController {
|
|||||||
'callback' => [__CLASS__, 'get_attributes'],
|
'callback' => [__CLASS__, 'get_attributes'],
|
||||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
'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'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -512,9 +575,10 @@ class ProductsController {
|
|||||||
$categories = [];
|
$categories = [];
|
||||||
foreach ($terms as $term) {
|
foreach ($terms as $term) {
|
||||||
$categories[] = [
|
$categories[] = [
|
||||||
'id' => $term->term_id,
|
'term_id' => $term->term_id,
|
||||||
'name' => $term->name,
|
'name' => $term->name,
|
||||||
'slug' => $term->slug,
|
'slug' => $term->slug,
|
||||||
|
'description' => $term->description,
|
||||||
'parent' => $term->parent,
|
'parent' => $term->parent,
|
||||||
'count' => $term->count,
|
'count' => $term->count,
|
||||||
];
|
];
|
||||||
@@ -539,9 +603,10 @@ class ProductsController {
|
|||||||
$tags = [];
|
$tags = [];
|
||||||
foreach ($terms as $term) {
|
foreach ($terms as $term) {
|
||||||
$tags[] = [
|
$tags[] = [
|
||||||
'id' => $term->term_id,
|
'term_id' => $term->term_id,
|
||||||
'name' => $term->name,
|
'name' => $term->name,
|
||||||
'slug' => $term->slug,
|
'slug' => $term->slug,
|
||||||
|
'description' => $term->description,
|
||||||
'count' => $term->count,
|
'count' => $term->count,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -558,11 +623,12 @@ class ProductsController {
|
|||||||
|
|
||||||
foreach ($attributes as $attribute) {
|
foreach ($attributes as $attribute) {
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'id' => $attribute->attribute_id,
|
'attribute_id' => $attribute->attribute_id,
|
||||||
'name' => $attribute->attribute_name,
|
'attribute_name' => $attribute->attribute_name,
|
||||||
'label' => $attribute->attribute_label,
|
'attribute_label' => $attribute->attribute_label,
|
||||||
'type' => $attribute->attribute_type,
|
'attribute_type' => $attribute->attribute_type,
|
||||||
'orderby' => $attribute->attribute_orderby,
|
'attribute_orderby' => $attribute->attribute_orderby,
|
||||||
|
'attribute_public' => $attribute->attribute_public,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,13 +921,6 @@ class ProductsController {
|
|||||||
continue;
|
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;
|
return $meta_data;
|
||||||
@@ -901,4 +960,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,7 @@ use WooNooW\Api\CouponsController;
|
|||||||
use WooNooW\Api\CustomersController;
|
use WooNooW\Api\CustomersController;
|
||||||
use WooNooW\Api\NewsletterController;
|
use WooNooW\Api\NewsletterController;
|
||||||
use WooNooW\Api\ModulesController;
|
use WooNooW\Api\ModulesController;
|
||||||
|
use WooNooW\Api\ModuleSettingsController;
|
||||||
use WooNooW\Frontend\ShopController;
|
use WooNooW\Frontend\ShopController;
|
||||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||||
use WooNooW\Frontend\AccountController;
|
use WooNooW\Frontend\AccountController;
|
||||||
@@ -128,6 +129,10 @@ class Routes {
|
|||||||
$modules_controller = new ModulesController();
|
$modules_controller = new ModulesController();
|
||||||
$modules_controller->register_routes();
|
$modules_controller->register_routes();
|
||||||
|
|
||||||
|
// Module Settings controller
|
||||||
|
$module_settings_controller = new ModuleSettingsController();
|
||||||
|
$module_settings_controller->register_routes();
|
||||||
|
|
||||||
// Frontend controllers (customer-facing)
|
// Frontend controllers (customer-facing)
|
||||||
ShopController::register_routes();
|
ShopController::register_routes();
|
||||||
FrontendCartController::register_routes();
|
FrontendCartController::register_routes();
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ class NavigationRegistry {
|
|||||||
[
|
[
|
||||||
'key' => 'dashboard',
|
'key' => 'dashboard',
|
||||||
'label' => __('Dashboard', 'woonoow'),
|
'label' => __('Dashboard', 'woonoow'),
|
||||||
'path' => '/',
|
'path' => '/dashboard',
|
||||||
'icon' => 'layout-dashboard',
|
'icon' => 'layout-dashboard',
|
||||||
'children' => [
|
'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' => __('Revenue', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/revenue'],
|
||||||
['label' => __('Orders', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/orders'],
|
['label' => __('Orders', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/orders'],
|
||||||
['label' => __('Products', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/products'],
|
['label' => __('Products', 'woonoow'), 'mode' => 'spa', 'path' => '/dashboard/products'],
|
||||||
@@ -127,7 +127,7 @@ class NavigationRegistry {
|
|||||||
'path' => '/orders',
|
'path' => '/orders',
|
||||||
'icon' => 'receipt-text',
|
'icon' => 'receipt-text',
|
||||||
'children' => [
|
'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'],
|
['label' => __('New', 'woonoow'), 'mode' => 'spa', 'path' => '/orders/new'],
|
||||||
// Future: Drafts, Recurring, etc.
|
// Future: Drafts, Recurring, etc.
|
||||||
],
|
],
|
||||||
@@ -138,7 +138,7 @@ class NavigationRegistry {
|
|||||||
'path' => '/products',
|
'path' => '/products',
|
||||||
'icon' => 'package',
|
'icon' => 'package',
|
||||||
'children' => [
|
'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' => __('New', 'woonoow'), 'mode' => 'spa', 'path' => '/products/new'],
|
||||||
['label' => __('Categories', 'woonoow'), 'mode' => 'spa', 'path' => '/products/categories'],
|
['label' => __('Categories', 'woonoow'), 'mode' => 'spa', 'path' => '/products/categories'],
|
||||||
['label' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'],
|
['label' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'],
|
||||||
@@ -151,7 +151,7 @@ class NavigationRegistry {
|
|||||||
'path' => '/customers',
|
'path' => '/customers',
|
||||||
'icon' => 'users',
|
'icon' => 'users',
|
||||||
'children' => [
|
'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'],
|
['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() {
|
public static function flush() {
|
||||||
delete_option(self::NAV_OPTION);
|
delete_option(self::NAV_OPTION);
|
||||||
|
// Rebuild immediately after flush
|
||||||
|
self::build_nav_tree();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ namespace WooNooW\Core;
|
|||||||
class ModuleRegistry {
|
class ModuleRegistry {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all registered modules
|
* Get built-in modules
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_all_modules() {
|
private static function get_builtin_modules() {
|
||||||
$modules = [
|
$modules = [
|
||||||
'newsletter' => [
|
'newsletter' => [
|
||||||
'id' => 'newsletter',
|
'id' => 'newsletter',
|
||||||
@@ -26,6 +26,7 @@ class ModuleRegistry {
|
|||||||
'category' => 'marketing',
|
'category' => 'marketing',
|
||||||
'icon' => 'mail',
|
'icon' => 'mail',
|
||||||
'default_enabled' => true,
|
'default_enabled' => true,
|
||||||
|
'has_settings' => true,
|
||||||
'features' => [
|
'features' => [
|
||||||
__('Subscriber management', 'woonoow'),
|
__('Subscriber management', 'woonoow'),
|
||||||
__('Email campaigns', 'woonoow'),
|
__('Email campaigns', 'woonoow'),
|
||||||
@@ -39,6 +40,7 @@ class ModuleRegistry {
|
|||||||
'category' => 'customers',
|
'category' => 'customers',
|
||||||
'icon' => 'heart',
|
'icon' => 'heart',
|
||||||
'default_enabled' => true,
|
'default_enabled' => true,
|
||||||
|
'has_settings' => true,
|
||||||
'features' => [
|
'features' => [
|
||||||
__('Save products to wishlist', 'woonoow'),
|
__('Save products to wishlist', 'woonoow'),
|
||||||
__('Wishlist page', '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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,10 +51,19 @@ class WishlistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is logged in
|
* Check if user is logged in OR guest wishlist is enabled
|
||||||
*/
|
*/
|
||||||
public static function check_permission() {
|
public static function check_permission() {
|
||||||
return is_user_logged_in();
|
// Allow if logged in
|
||||||
|
if (is_user_logged_in()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if guest wishlist is enabled
|
||||||
|
$settings = get_option('woonoow_module_wishlist_settings', []);
|
||||||
|
$enable_guest = $settings['enable_guest_wishlist'] ?? true;
|
||||||
|
|
||||||
|
return $enable_guest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +116,10 @@ class WishlistController {
|
|||||||
return new WP_Error('module_disabled', __('Wishlist module is disabled', 'woonoow'), ['status' => 403]);
|
return new WP_Error('module_disabled', __('Wishlist module is disabled', 'woonoow'), ['status' => 403]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get settings
|
||||||
|
$settings = get_option('woonoow_module_wishlist_settings', []);
|
||||||
|
$max_items = (int) ($settings['max_items_per_wishlist'] ?? 0);
|
||||||
|
|
||||||
$user_id = get_current_user_id();
|
$user_id = get_current_user_id();
|
||||||
$product_id = $request->get_param('product_id');
|
$product_id = $request->get_param('product_id');
|
||||||
|
|
||||||
@@ -121,6 +134,15 @@ class WishlistController {
|
|||||||
$wishlist = [];
|
$wishlist = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check max items limit
|
||||||
|
if ($max_items > 0 && count($wishlist) >= $max_items) {
|
||||||
|
return new WP_Error(
|
||||||
|
'wishlist_limit_reached',
|
||||||
|
sprintf(__('Wishlist limit reached. Maximum %d items allowed.', 'woonoow'), $max_items),
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already in wishlist
|
// Check if already in wishlist
|
||||||
foreach ($wishlist as $item) {
|
foreach ($wishlist as $item) {
|
||||||
if ($item['product_id'] === $product_id) {
|
if ($item['product_id'] === $product_id) {
|
||||||
|
|||||||
96
includes/Modules/NewsletterSettings.php
Normal file
96
includes/Modules/NewsletterSettings.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Newsletter Module Settings Schema
|
||||||
|
*
|
||||||
|
* Example of schema-based settings for the Newsletter module
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules;
|
||||||
|
|
||||||
|
class NewsletterSettings {
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
// Register settings schema
|
||||||
|
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register newsletter settings schema
|
||||||
|
*/
|
||||||
|
public static function register_schema($schemas) {
|
||||||
|
$schemas['newsletter'] = [
|
||||||
|
'sender_name' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'label' => __('Sender Name', 'woonoow'),
|
||||||
|
'description' => __('The name that appears in the "From" field of newsletter emails', 'woonoow'),
|
||||||
|
'placeholder' => get_bloginfo('name'),
|
||||||
|
'default' => get_bloginfo('name'),
|
||||||
|
'required' => true,
|
||||||
|
],
|
||||||
|
'sender_email' => [
|
||||||
|
'type' => 'email',
|
||||||
|
'label' => __('Sender Email', 'woonoow'),
|
||||||
|
'description' => __('The email address that appears in the "From" field', 'woonoow'),
|
||||||
|
'placeholder' => get_option('admin_email'),
|
||||||
|
'default' => get_option('admin_email'),
|
||||||
|
'required' => true,
|
||||||
|
],
|
||||||
|
'reply_to_email' => [
|
||||||
|
'type' => 'email',
|
||||||
|
'label' => __('Reply-To Email', 'woonoow'),
|
||||||
|
'description' => __('Email address for replies (leave empty to use sender email)', 'woonoow'),
|
||||||
|
'placeholder' => get_option('admin_email'),
|
||||||
|
],
|
||||||
|
'double_opt_in' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Double Opt-In', 'woonoow'),
|
||||||
|
'description' => __('Require subscribers to confirm their email address before being added to the list', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'welcome_email' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Send Welcome Email', 'woonoow'),
|
||||||
|
'description' => __('Automatically send a welcome email to new subscribers', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'unsubscribe_page' => [
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => __('Unsubscribe Page', 'woonoow'),
|
||||||
|
'description' => __('Page to redirect users after unsubscribing', 'woonoow'),
|
||||||
|
'placeholder' => __('-- Select Page --', 'woonoow'),
|
||||||
|
'options' => self::get_pages_options(),
|
||||||
|
],
|
||||||
|
'gdpr_consent' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('GDPR Consent Checkbox', 'woonoow'),
|
||||||
|
'description' => __('Show a consent checkbox on subscription forms (recommended for EU compliance)', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'consent_text' => [
|
||||||
|
'type' => 'textarea',
|
||||||
|
'label' => __('Consent Text', 'woonoow'),
|
||||||
|
'description' => __('Text shown next to the consent checkbox', 'woonoow'),
|
||||||
|
'placeholder' => __('I agree to receive marketing emails', 'woonoow'),
|
||||||
|
'default' => __('I agree to receive marketing emails and understand I can unsubscribe at any time.', 'woonoow'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $schemas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pages as options for select field
|
||||||
|
*/
|
||||||
|
private static function get_pages_options() {
|
||||||
|
$pages = get_pages();
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
$options[$page->ID] = $page->post_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
includes/Modules/WishlistSettings.php
Normal file
79
includes/Modules/WishlistSettings.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Wishlist Module Settings
|
||||||
|
*
|
||||||
|
* @package WooNooW
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class WishlistSettings {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the settings
|
||||||
|
*/
|
||||||
|
public static function init() {
|
||||||
|
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register wishlist settings schema
|
||||||
|
*/
|
||||||
|
public static function register_schema($schemas) {
|
||||||
|
$schemas['wishlist'] = [
|
||||||
|
'enable_guest_wishlist' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Enable Guest Wishlists', 'woonoow'),
|
||||||
|
'description' => __('Allow non-logged-in users to create wishlists (stored in browser)', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'show_in_header' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Show Wishlist Icon in Header', 'woonoow'),
|
||||||
|
'description' => __('Display wishlist icon with item count in the header', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'max_items_per_wishlist' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Maximum Items Per Wishlist', 'woonoow'),
|
||||||
|
'description' => __('Limit the number of items in a wishlist (0 = unlimited)', 'woonoow'),
|
||||||
|
'default' => 0,
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 1000,
|
||||||
|
],
|
||||||
|
'show_add_to_cart_button' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Show "Add to Cart" on Wishlist Page', 'woonoow'),
|
||||||
|
'description' => __('Display add to cart button for each wishlist item', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
// Advanced features - Coming Soon
|
||||||
|
'enable_sharing' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Enable Wishlist Sharing (Coming Soon)', 'woonoow'),
|
||||||
|
'description' => __('Allow users to share their wishlists via link - Feature not yet implemented', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
'disabled' => true,
|
||||||
|
],
|
||||||
|
'enable_email_notifications' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Back in Stock Notifications (Coming Soon)', 'woonoow'),
|
||||||
|
'description' => __('Email users when wishlist items are back in stock - Feature not yet implemented', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
'disabled' => true,
|
||||||
|
],
|
||||||
|
'enable_multiple_wishlists' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Enable Multiple Wishlists (Coming Soon)', 'woonoow'),
|
||||||
|
'description' => __('Allow users to create multiple named wishlists - Feature not yet implemented', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
'disabled' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $schemas;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
161
types/woonoow-addon.d.ts
vendored
Normal file
161
types/woonoow-addon.d.ts
vendored
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* WooNooW Addon TypeScript Definitions
|
||||||
|
*
|
||||||
|
* Type definitions for addon developers using the WooNooW API
|
||||||
|
*
|
||||||
|
* @package WooNooW
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* WooNooW API exposed to addon developers
|
||||||
|
*/
|
||||||
|
WooNooW: {
|
||||||
|
React: typeof import('react');
|
||||||
|
ReactDOM: typeof import('react-dom/client');
|
||||||
|
|
||||||
|
hooks: {
|
||||||
|
useQuery: any;
|
||||||
|
useMutation: any;
|
||||||
|
useQueryClient: any;
|
||||||
|
useModules: () => {
|
||||||
|
isEnabled: (moduleId: string) => boolean;
|
||||||
|
modules: string[];
|
||||||
|
};
|
||||||
|
useModuleSettings: (moduleId: string) => {
|
||||||
|
settings: Record<string, any>;
|
||||||
|
isLoading: boolean;
|
||||||
|
updateSettings: {
|
||||||
|
mutate: (settings: Record<string, any>) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
saveSetting: (key: string, value: any) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Button: ComponentType<any>;
|
||||||
|
Input: ComponentType<any>;
|
||||||
|
Label: ComponentType<any>;
|
||||||
|
Textarea: ComponentType<any>;
|
||||||
|
Switch: ComponentType<any>;
|
||||||
|
Select: ComponentType<any>;
|
||||||
|
SelectContent: ComponentType<any>;
|
||||||
|
SelectItem: ComponentType<any>;
|
||||||
|
SelectTrigger: ComponentType<any>;
|
||||||
|
SelectValue: ComponentType<any>;
|
||||||
|
Checkbox: ComponentType<any>;
|
||||||
|
Badge: ComponentType<any>;
|
||||||
|
Card: ComponentType<any>;
|
||||||
|
CardContent: ComponentType<any>;
|
||||||
|
CardDescription: ComponentType<any>;
|
||||||
|
CardFooter: ComponentType<any>;
|
||||||
|
CardHeader: ComponentType<any>;
|
||||||
|
CardTitle: ComponentType<any>;
|
||||||
|
SettingsLayout: ComponentType<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
SettingsCard: ComponentType<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
SettingsSection: ComponentType<any>;
|
||||||
|
SchemaForm: ComponentType<{
|
||||||
|
schema: FormSchema;
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
onSubmit: (values: Record<string, any>) => void | Promise<void>;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
submitLabel?: string;
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
SchemaField: ComponentType<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
icons: {
|
||||||
|
Settings: ComponentType<any>;
|
||||||
|
Save: ComponentType<any>;
|
||||||
|
Trash2: ComponentType<any>;
|
||||||
|
Edit: ComponentType<any>;
|
||||||
|
Plus: ComponentType<any>;
|
||||||
|
X: ComponentType<any>;
|
||||||
|
Check: ComponentType<any>;
|
||||||
|
AlertCircle: ComponentType<any>;
|
||||||
|
Info: ComponentType<any>;
|
||||||
|
Loader2: ComponentType<any>;
|
||||||
|
ChevronDown: ComponentType<any>;
|
||||||
|
ChevronUp: ComponentType<any>;
|
||||||
|
ChevronLeft: ComponentType<any>;
|
||||||
|
ChevronRight: ComponentType<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
utils: {
|
||||||
|
api: {
|
||||||
|
get: (endpoint: string) => Promise<any>;
|
||||||
|
post: (endpoint: string, data?: any) => Promise<any>;
|
||||||
|
put: (endpoint: string, data?: any) => Promise<any>;
|
||||||
|
delete: (endpoint: string) => Promise<any>;
|
||||||
|
};
|
||||||
|
toast: {
|
||||||
|
success: (message: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
info: (message: string) => void;
|
||||||
|
warning: (message: string) => void;
|
||||||
|
};
|
||||||
|
__: (text: string, domain?: string) => string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form Schema Types
|
||||||
|
*/
|
||||||
|
export type FieldType = 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select';
|
||||||
|
|
||||||
|
export interface FieldSchema {
|
||||||
|
type: FieldType;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: any;
|
||||||
|
options?: Record<string, string>;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormSchema = Record<string, FieldSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module Registration
|
||||||
|
*/
|
||||||
|
export interface ModuleRegistration {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
category: 'marketing' | 'customers' | 'products' | 'shipping' | 'payments' | 'analytics' | 'other';
|
||||||
|
icon: string;
|
||||||
|
features: string[];
|
||||||
|
has_settings?: boolean;
|
||||||
|
settings_component?: string;
|
||||||
|
spa_bundle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings Schema Registration
|
||||||
|
*/
|
||||||
|
export interface SettingsSchemaRegistration {
|
||||||
|
[moduleId: string]: FormSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -36,6 +36,10 @@ add_action('plugins_loaded', function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
WooNooW\Core\Bootstrap::init();
|
WooNooW\Core\Bootstrap::init();
|
||||||
|
|
||||||
|
// Initialize module settings
|
||||||
|
WooNooW\Modules\NewsletterSettings::init();
|
||||||
|
WooNooW\Modules\WishlistSettings::init();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activation/Deactivation hooks
|
// Activation/Deactivation hooks
|
||||||
|
|||||||
Reference in New Issue
Block a user