docs: Update progress and SOP with CRUD pattern
Updated documentation with latest progress and standardized CRUD pattern. PROGRESS_NOTE.md Updates: - Email notification enhancements (variable dropdown, card reorganization) - Card styling fixes (success = green, not purple) - List support verification - Product CRUD backend API complete (600+ lines) - All endpoints: list, get, create, update, delete - Full variant support for variable products - Categories, tags, attributes endpoints PROJECT_SOP.md Updates: - Added Section 6.9: CRUD Module Pattern (Standard Template) - Complete file structure template - Backend API pattern with code examples - Frontend index/create/edit page patterns - Comprehensive checklist for new modules - Based on Orders module analysis - Ready to use for Products, Customers, Coupons, etc. Benefits: - Consistent pattern across all modules - Faster development (copy-paste template) - Standardized UX and code structure - Clear checklist for implementation - Reference implementation documented
This commit is contained in:
320
PROGRESS_NOTE.md
320
PROGRESS_NOTE.md
@@ -1,6 +1,6 @@
|
|||||||
# WooNooW Project Progress Note
|
# WooNooW Project Progress Note
|
||||||
|
|
||||||
**Last Updated:** November 11, 2025, 4:10 PM (GMT+7)
|
**Last Updated:** November 19, 2025, 6:50 PM (GMT+7)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
WooNooW is a hybrid WordPress + React SPA replacement for WooCommerce Admin. It focuses on performance, UX consistency, and extensibility with SSR-safe endpoints and REST-first design. The plugin integrates deeply with WooCommerce’s data store (HPOS ready) and provides a modern React-based dashboard and order management system.
|
WooNooW is a hybrid WordPress + React SPA replacement for WooCommerce Admin. It focuses on performance, UX consistency, and extensibility with SSR-safe endpoints and REST-first design. The plugin integrates deeply with WooCommerce’s data store (HPOS ready) and provides a modern React-based dashboard and order management system.
|
||||||
@@ -2720,3 +2720,321 @@ $notification_urls = [
|
|||||||
**Implementation Date:** November 11, 2025
|
**Implementation Date:** November 11, 2025
|
||||||
**Status:** ✅ Production Ready
|
**Status:** ✅ Production Ready
|
||||||
**Next Milestone:** Dynamic push notification URLs
|
**Next Milestone:** Dynamic push notification URLs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📧 Email Notification System Enhancements — November 19, 2025
|
||||||
|
|
||||||
|
### ✅ Variable Dropdown in TipTap Editor - COMPLETE
|
||||||
|
|
||||||
|
**Problem:** Users had to manually type `{variable_name}` placeholders in email templates.
|
||||||
|
|
||||||
|
**Solution:** Added comprehensive variable dropdown with 40+ available variables.
|
||||||
|
|
||||||
|
#### **Frontend Changes**
|
||||||
|
|
||||||
|
**File:** `admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx`
|
||||||
|
|
||||||
|
**Added variables list** (lines 42-87):
|
||||||
|
```typescript
|
||||||
|
const availableVariables = [
|
||||||
|
// Order variables (14)
|
||||||
|
'order_number', 'order_id', 'order_date', 'order_total',
|
||||||
|
'order_subtotal', 'order_tax', 'order_shipping', 'order_discount',
|
||||||
|
'order_status', 'order_url', 'order_items_table',
|
||||||
|
'completion_date', 'estimated_delivery',
|
||||||
|
|
||||||
|
// Customer variables (7)
|
||||||
|
'customer_name', 'customer_first_name', 'customer_last_name',
|
||||||
|
'customer_email', 'customer_phone',
|
||||||
|
'billing_address', 'shipping_address',
|
||||||
|
|
||||||
|
// Payment variables (5)
|
||||||
|
'payment_method', 'payment_status', 'payment_date',
|
||||||
|
'transaction_id', 'payment_retry_url',
|
||||||
|
|
||||||
|
// Shipping/Tracking variables (4)
|
||||||
|
'tracking_number', 'tracking_url',
|
||||||
|
'shipping_carrier', 'shipping_method',
|
||||||
|
|
||||||
|
// URL variables (3)
|
||||||
|
'review_url', 'shop_url', 'my_account_url',
|
||||||
|
|
||||||
|
// Store variables (6)
|
||||||
|
'site_name', 'site_title', 'store_name',
|
||||||
|
'store_url', 'support_email', 'current_year',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ Dropdown appears below TipTap editor
|
||||||
|
- ✅ Shows all 40+ available variables
|
||||||
|
- ✅ Click to insert at cursor position
|
||||||
|
- ✅ Formatted as `{variable_name}`
|
||||||
|
- ✅ Categorized by type (Order, Customer, Payment, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Notification Page Reorganization - COMPLETE
|
||||||
|
|
||||||
|
**Problem:** Flat card layout made it hard to scale for future recipient types (Affiliate, Merchant).
|
||||||
|
|
||||||
|
**Solution:** Categorized cards into "Recipients" and "Channels" sections.
|
||||||
|
|
||||||
|
#### **Frontend Changes**
|
||||||
|
|
||||||
|
**File:** `admin-spa/src/routes/Settings/Notifications.tsx`
|
||||||
|
|
||||||
|
**New Structure:**
|
||||||
|
```
|
||||||
|
📧 Notifications
|
||||||
|
├── 👥 Recipients
|
||||||
|
│ ├── Staff (Orders, Products, Customers)
|
||||||
|
│ └── Customer (Orders, Shipping, Account)
|
||||||
|
│
|
||||||
|
└── 📡 Channels
|
||||||
|
├── Channel Configuration (Email, Push, WhatsApp, Telegram)
|
||||||
|
└── Activity Log (Coming soon)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Clear separation of concerns
|
||||||
|
- ✅ Easy to add new recipients (Affiliate, Merchant)
|
||||||
|
- ✅ Scalable structure
|
||||||
|
- ✅ Better UX with section headers
|
||||||
|
- ✅ Professional organization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Email Card Styling Fixes - COMPLETE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
1. `[card type="success"]` rendered with hero gradient (purple) instead of green
|
||||||
|
2. List support verification needed
|
||||||
|
|
||||||
|
**Solution:** Fixed card rendering in EmailRenderer.
|
||||||
|
|
||||||
|
#### **Backend Changes**
|
||||||
|
|
||||||
|
**File:** `includes/Core/Notifications/EmailRenderer.php` (lines 348-380)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```php
|
||||||
|
if ($type === 'hero' || $type === 'success') {
|
||||||
|
// Both used same gradient ❌
|
||||||
|
$style .= ' background: linear-gradient(...)';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```php
|
||||||
|
if ($type === 'hero') {
|
||||||
|
// Hero: gradient background
|
||||||
|
$style .= ' background: linear-gradient(...)';
|
||||||
|
}
|
||||||
|
elseif ($type === 'success') {
|
||||||
|
// Success: green theme ✅
|
||||||
|
$style .= ' background-color: #f0fdf4; border-left: 4px solid #22c55e;';
|
||||||
|
}
|
||||||
|
elseif ($type === 'info') {
|
||||||
|
// Info: blue theme
|
||||||
|
$style .= ' background-color: #f0f7ff; border-left: 4px solid #0071e3;';
|
||||||
|
}
|
||||||
|
elseif ($type === 'warning') {
|
||||||
|
// Warning: orange theme
|
||||||
|
$style .= ' background-color: #fff8e1; border-left: 4px solid #ff9800;';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Card Types Now:**
|
||||||
|
- `default`: white background
|
||||||
|
- `hero`: gradient background (purple)
|
||||||
|
- `success`: green background with left border ✅
|
||||||
|
- `info`: blue background with left border
|
||||||
|
- `warning`: orange background with left border
|
||||||
|
|
||||||
|
**List Support:**
|
||||||
|
- ✅ Already working in MarkdownParser (lines 132-141)
|
||||||
|
- ✅ Supports: `*`, `-`, `•`, `✓`, `✔` as list markers
|
||||||
|
- ✅ Properly converts to `<ul><li>` HTML
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 Files Changed
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `admin-spa/src/routes/Settings/Notifications.tsx` - Card reorganization
|
||||||
|
- `admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx` - Variable dropdown
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `includes/Core/Notifications/EmailRenderer.php` - Card styling fixes
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
- `includes/Email/TEMPLATE_USAGE_GUIDE.md` - Updated with variable list
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛍️ Product CRUD Module — November 19, 2025 (In Progress)
|
||||||
|
|
||||||
|
### ✅ Backend API - COMPLETE
|
||||||
|
|
||||||
|
**Goal:** Comprehensive REST API for WooCommerce product management following Orders module pattern.
|
||||||
|
|
||||||
|
#### **Backend Implementation**
|
||||||
|
|
||||||
|
**File:** `includes/Api/ProductsController.php` (600+ lines)
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
```php
|
||||||
|
GET /products // List with filters
|
||||||
|
GET /products/{id} // Single product details
|
||||||
|
POST /products // Create product
|
||||||
|
PUT /products/{id} // Update product
|
||||||
|
DELETE /products/{id} // Delete product
|
||||||
|
GET /products/categories // Categories list
|
||||||
|
GET /products/tags // Tags list
|
||||||
|
GET /products/attributes // Attributes list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
**1. List Products** (`get_products`)
|
||||||
|
- ✅ Pagination (page, per_page)
|
||||||
|
- ✅ Search by name/SKU
|
||||||
|
- ✅ Filter by status (publish, draft, pending, private)
|
||||||
|
- ✅ Filter by category
|
||||||
|
- ✅ Filter by type (simple, variable)
|
||||||
|
- ✅ Filter by stock status (instock, outofstock, onbackorder)
|
||||||
|
- ✅ Sort by date, ID, modified, title
|
||||||
|
- ✅ Returns: id, name, SKU, type, status, price, stock, image
|
||||||
|
|
||||||
|
**2. Get Single Product** (`get_product`)
|
||||||
|
- ✅ Full product details
|
||||||
|
- ✅ Description & short description
|
||||||
|
- ✅ Dimensions (weight, length, width, height)
|
||||||
|
- ✅ Categories & tags
|
||||||
|
- ✅ Images & gallery
|
||||||
|
- ✅ For variable products: attributes & variations
|
||||||
|
|
||||||
|
**3. Create Product** (`create_product`)
|
||||||
|
- ✅ Simple & Variable product support
|
||||||
|
- ✅ Basic data (name, slug, status, description, SKU, prices)
|
||||||
|
- ✅ Stock management (manage_stock, stock_quantity, stock_status)
|
||||||
|
- ✅ Dimensions & weight
|
||||||
|
- ✅ Categories & tags
|
||||||
|
- ✅ Images & gallery
|
||||||
|
- ✅ Attributes (for variable products)
|
||||||
|
- ✅ Variations (for variable products)
|
||||||
|
|
||||||
|
**4. Update Product** (`update_product`)
|
||||||
|
- ✅ Update all product fields
|
||||||
|
- ✅ Update variations
|
||||||
|
- ✅ Update attributes
|
||||||
|
- ✅ Partial updates supported
|
||||||
|
|
||||||
|
**5. Delete Product** (`delete_product`)
|
||||||
|
- ✅ Soft delete (to trash)
|
||||||
|
- ✅ Force delete option
|
||||||
|
- ✅ Deletes variations automatically
|
||||||
|
|
||||||
|
**6. Helper Methods**
|
||||||
|
- `get_categories()` - All product categories
|
||||||
|
- `get_tags()` - All product tags
|
||||||
|
- `get_attributes()` - All product attributes
|
||||||
|
- `format_product_list_item()` - Format for list view
|
||||||
|
- `format_product_full()` - Format with full details
|
||||||
|
- `save_product_attributes()` - Save attributes for variable products
|
||||||
|
- `save_product_variations()` - Save variations
|
||||||
|
|
||||||
|
**Variation Support:**
|
||||||
|
```php
|
||||||
|
// Attributes
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Size",
|
||||||
|
"options": ["S", "M", "L"],
|
||||||
|
"variation": true,
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Color",
|
||||||
|
"options": ["Red", "Blue"],
|
||||||
|
"variation": true,
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Variations
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sku": "TSHIRT-S-RED",
|
||||||
|
"regular_price": "25.00",
|
||||||
|
"stock_quantity": 10,
|
||||||
|
"attributes": {
|
||||||
|
"Size": "S",
|
||||||
|
"Color": "Red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Routes Registration**
|
||||||
|
|
||||||
|
**File:** `includes/Api/Routes.php`
|
||||||
|
|
||||||
|
**Added:**
|
||||||
|
```php
|
||||||
|
use WooNooW\Api\ProductsController;
|
||||||
|
|
||||||
|
// In rest_api_init:
|
||||||
|
ProductsController::register_routes();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🎯 Next Steps (Frontend)
|
||||||
|
|
||||||
|
**Pending:**
|
||||||
|
1. Create Products index page (table + cards like Orders)
|
||||||
|
2. Create Product New/Create form with variants support
|
||||||
|
3. Create Product Edit page
|
||||||
|
4. Add bulk delete functionality
|
||||||
|
5. Test complete CRUD flow
|
||||||
|
|
||||||
|
**Pattern to Follow:**
|
||||||
|
- Study `admin-spa/src/routes/Orders/` structure
|
||||||
|
- Replicate for Products module
|
||||||
|
- Use same components (filters, search, pagination)
|
||||||
|
- Follow same UX patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 Summary
|
||||||
|
|
||||||
|
**Completed Today (November 19, 2025):**
|
||||||
|
1. ✅ Variable dropdown in email template editor (40+ variables)
|
||||||
|
2. ✅ Notification page reorganization (Recipients + Channels)
|
||||||
|
3. ✅ Email card styling fixes (success = green, not purple)
|
||||||
|
4. ✅ List support verification (already working)
|
||||||
|
5. ✅ Product CRUD backend API (complete with variants)
|
||||||
|
6. ✅ Routes registration for Products API
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `includes/Api/ProductsController.php` (600+ lines)
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `admin-spa/src/routes/Settings/Notifications.tsx`
|
||||||
|
- `admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx`
|
||||||
|
- `includes/Core/Notifications/EmailRenderer.php`
|
||||||
|
- `includes/Api/Routes.php`
|
||||||
|
|
||||||
|
**Next Session:**
|
||||||
|
- Build Product CRUD frontend (index, create, edit)
|
||||||
|
- Follow Orders module pattern
|
||||||
|
- Support simple & variable products
|
||||||
|
- Add image upload functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last synced:** 2025-11-19 18:50 GMT+7
|
||||||
|
**Next milestone:** Complete Product CRUD frontend implementation
|
||||||
|
|||||||
448
PROJECT_SOP.md
448
PROJECT_SOP.md
@@ -1157,6 +1157,454 @@ Use Orders as the template for building new core modules.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6.9 CRUD Module Pattern (Standard Template)
|
||||||
|
|
||||||
|
**All CRUD modules (Orders, Products, Customers, Coupons, etc.) MUST follow this exact pattern for consistency.**
|
||||||
|
|
||||||
|
### 📁 File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
admin-spa/src/routes/{Module}/
|
||||||
|
├── index.tsx # List view (table + filters)
|
||||||
|
├── New.tsx # Create new item
|
||||||
|
├── Edit.tsx # Edit existing item
|
||||||
|
├── Detail.tsx # View item details (optional)
|
||||||
|
├── components/ # Module-specific components
|
||||||
|
│ ├── {Module}Card.tsx # Mobile card view
|
||||||
|
│ ├── FilterBottomSheet.tsx # Mobile filters
|
||||||
|
│ └── SearchBar.tsx # Search component
|
||||||
|
└── partials/ # Shared form components
|
||||||
|
└── {Module}Form.tsx # Reusable form for create/edit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎯 Backend API Pattern
|
||||||
|
|
||||||
|
**File:** `includes/Api/{Module}Controller.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace WooNooW\Api;
|
||||||
|
|
||||||
|
class {Module}Controller {
|
||||||
|
|
||||||
|
public static function register_routes() {
|
||||||
|
// List
|
||||||
|
register_rest_route('woonoow/v1', '/{module}', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_{module}'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Single
|
||||||
|
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_{item}'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create
|
||||||
|
register_rest_route('woonoow/v1', '/{module}', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'create_{item}'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
|
||||||
|
'methods' => 'PUT',
|
||||||
|
'callback' => [__CLASS__, 'update_{item}'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
|
||||||
|
'methods' => 'DELETE',
|
||||||
|
'callback' => [__CLASS__, 'delete_{item}'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List with pagination & filters
|
||||||
|
public static function get_{module}(WP_REST_Request $request) {
|
||||||
|
$page = max(1, (int) $request->get_param('page'));
|
||||||
|
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
||||||
|
$search = $request->get_param('search');
|
||||||
|
$status = $request->get_param('status');
|
||||||
|
$orderby = $request->get_param('orderby') ?: 'date';
|
||||||
|
$order = $request->get_param('order') ?: 'DESC';
|
||||||
|
|
||||||
|
// Query logic here
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'rows' => $items,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $per_page,
|
||||||
|
'pages' => $max_pages,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register in Routes.php:**
|
||||||
|
```php
|
||||||
|
use WooNooW\Api\{Module}Controller;
|
||||||
|
|
||||||
|
// In rest_api_init:
|
||||||
|
{Module}Controller::register_routes();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎨 Frontend Index Page Pattern
|
||||||
|
|
||||||
|
**File:** `admin-spa/src/routes/{Module}/index.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
|
import { setQuery, getQuery } from '@/lib/query-params';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function {Module}Index() {
|
||||||
|
useFABConfig('{module}'); // Enable FAB for create
|
||||||
|
|
||||||
|
const initial = getQuery();
|
||||||
|
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||||
|
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const perPage = 20;
|
||||||
|
|
||||||
|
// Sync URL params
|
||||||
|
React.useEffect(() => {
|
||||||
|
setQuery({ page, status });
|
||||||
|
}, [page, status]);
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['{module}', { page, perPage, status }],
|
||||||
|
queryFn: () => api.get('/{module}', {
|
||||||
|
page, per_page: perPage, status
|
||||||
|
}),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = q.data as undefined | { rows: any[]; total: number };
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
const filteredItems = React.useMemo(() => {
|
||||||
|
const rows = data?.rows;
|
||||||
|
if (!rows) return [];
|
||||||
|
if (!searchQuery.trim()) return rows;
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return rows.filter((item: any) =>
|
||||||
|
item.name?.toLowerCase().includes(query) ||
|
||||||
|
item.id?.toString().includes(query)
|
||||||
|
);
|
||||||
|
}, [data, searchQuery]);
|
||||||
|
|
||||||
|
// Bulk delete
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (ids: number[]) => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
ids.map(id => api.del(`/{module}/${id}`))
|
||||||
|
);
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
return { total: ids.length, failed };
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
const { total, failed } = result;
|
||||||
|
if (failed === 0) {
|
||||||
|
toast.success(__('Items deleted successfully'));
|
||||||
|
} else if (failed < total) {
|
||||||
|
toast.warning(__(`${total - failed} deleted, ${failed} failed`));
|
||||||
|
} else {
|
||||||
|
toast.error(__('Failed to delete items'));
|
||||||
|
}
|
||||||
|
setSelectedIds([]);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
q.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox handlers
|
||||||
|
const allIds = filteredItems.map(r => r.id) || [];
|
||||||
|
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setSelectedIds(allSelected ? [] : allIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (id: number) => {
|
||||||
|
setSelectedIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 w-full pb-4">
|
||||||
|
{/* Desktop: Filters */}
|
||||||
|
<div className="hidden md:block rounded-lg border p-4">
|
||||||
|
{/* Filter controls */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: Search + Filter */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
onFilterClick={() => setFilterSheetOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||||
|
<th>{__('Name')}</th>
|
||||||
|
<th>{__('Status')}</th>
|
||||||
|
<th>{__('Actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td><Checkbox checked={selectedIds.includes(item.id)} onCheckedChange={() => toggleRow(item.id)} /></td>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td><StatusBadge value={item.status} /></td>
|
||||||
|
<td><Link to={`/{module}/${item.id}`}>{__('View')}</Link></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: Cards */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<{Module}Card key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Dialog */}
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
{/* Dialog content */}
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📝 Frontend Create Page Pattern
|
||||||
|
|
||||||
|
**File:** `admin-spa/src/routes/{Module}/New.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {Module}Form from './partials/{Module}Form';
|
||||||
|
|
||||||
|
export default function {Module}New() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useFABConfig('none'); // Hide FAB on create page
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: (data: any) => api.post('/{module}', data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['{module}'] });
|
||||||
|
showSuccessToast(__('Item created successfully'));
|
||||||
|
nav('/{module}');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
showErrorToast(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set page header
|
||||||
|
useEffect(() => {
|
||||||
|
const actions = (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => nav('/{module}')}>
|
||||||
|
{__('Back')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => formRef.current?.requestSubmit()}
|
||||||
|
disabled={mutate.isPending}
|
||||||
|
>
|
||||||
|
{mutate.isPending ? __('Creating...') : __('Create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
setPageHeader(__('New {Item}'), actions);
|
||||||
|
return () => clearPageHeader();
|
||||||
|
}, [mutate.isPending, setPageHeader, clearPageHeader, nav]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<{Module}Form
|
||||||
|
mode="create"
|
||||||
|
formRef={formRef}
|
||||||
|
hideSubmitButton={true}
|
||||||
|
onSubmit={(form) => mutate.mutate(form)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✏️ Frontend Edit Page Pattern
|
||||||
|
|
||||||
|
**File:** `admin-spa/src/routes/{Module}/Edit.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {Module}Form from './partials/{Module}Form';
|
||||||
|
|
||||||
|
export default function {Module}Edit() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const itemId = Number(id);
|
||||||
|
const nav = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useFABConfig('none');
|
||||||
|
|
||||||
|
const itemQ = useQuery({
|
||||||
|
queryKey: ['{item}', itemId],
|
||||||
|
enabled: Number.isFinite(itemId),
|
||||||
|
queryFn: () => api.get(`/{module}/${itemId}`)
|
||||||
|
});
|
||||||
|
|
||||||
|
const upd = useMutation({
|
||||||
|
mutationFn: (payload: any) => api.put(`/{module}/${itemId}`, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['{module}'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['{item}', itemId] });
|
||||||
|
showSuccessToast(__('Item updated successfully'));
|
||||||
|
nav(`/{module}/${itemId}`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
showErrorToast(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = itemQ.data || {};
|
||||||
|
|
||||||
|
// Set page header
|
||||||
|
useEffect(() => {
|
||||||
|
const actions = (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => nav(`/{module}/${itemId}`)}>
|
||||||
|
{__('Back')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => formRef.current?.requestSubmit()}
|
||||||
|
disabled={upd.isPending}
|
||||||
|
>
|
||||||
|
{upd.isPending ? __('Saving...') : __('Save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
setPageHeader(__('Edit {Item}'), actions);
|
||||||
|
return () => clearPageHeader();
|
||||||
|
}, [itemId, upd.isPending, setPageHeader, clearPageHeader, nav]);
|
||||||
|
|
||||||
|
if (!Number.isFinite(itemId)) {
|
||||||
|
return <div className="p-4 text-sm text-red-600">{__('Invalid ID')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemQ.isLoading) {
|
||||||
|
return <LoadingState message={__('Loading...')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemQ.isError) {
|
||||||
|
return <ErrorCard
|
||||||
|
title={__('Failed to load item')}
|
||||||
|
message={getPageLoadErrorMessage(itemQ.error)}
|
||||||
|
onRetry={() => itemQ.refetch()}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<{Module}Form
|
||||||
|
mode="edit"
|
||||||
|
initial={item}
|
||||||
|
formRef={formRef}
|
||||||
|
hideSubmitButton={true}
|
||||||
|
onSubmit={(form) => upd.mutate(form)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📋 Checklist for New CRUD Module
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- [ ] Create `{Module}Controller.php` with all CRUD endpoints
|
||||||
|
- [ ] Register routes in `Routes.php`
|
||||||
|
- [ ] Add permission checks (`Permissions::check_admin`)
|
||||||
|
- [ ] Implement pagination, filters, search
|
||||||
|
- [ ] Return consistent response format
|
||||||
|
- [ ] Add i18n for all error messages
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- [ ] Create `routes/{Module}/index.tsx` (list view)
|
||||||
|
- [ ] Create `routes/{Module}/New.tsx` (create)
|
||||||
|
- [ ] Create `routes/{Module}/Edit.tsx` (edit)
|
||||||
|
- [ ] Create `routes/{Module}/Detail.tsx` (optional view)
|
||||||
|
- [ ] Create `components/{Module}Card.tsx` (mobile)
|
||||||
|
- [ ] Create `partials/{Module}Form.tsx` (reusable form)
|
||||||
|
- [ ] Add to navigation tree (`nav/tree.ts`)
|
||||||
|
- [ ] Configure FAB (`useFABConfig`)
|
||||||
|
- [ ] Add all i18n strings
|
||||||
|
- [ ] Implement bulk delete
|
||||||
|
- [ ] Add filters (status, date, search)
|
||||||
|
- [ ] Add pagination
|
||||||
|
- [ ] Test mobile responsive
|
||||||
|
- [ ] Test error states
|
||||||
|
- [ ] Test loading states
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- [ ] Create item
|
||||||
|
- [ ] Edit item
|
||||||
|
- [ ] Delete item
|
||||||
|
- [ ] Bulk delete
|
||||||
|
- [ ] Search
|
||||||
|
- [ ] Filter by status
|
||||||
|
- [ ] Pagination
|
||||||
|
- [ ] Mobile view
|
||||||
|
- [ ] Error handling
|
||||||
|
- [ ] Permission checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 7. 🎨 Admin Interface Modes
|
## 7. 🎨 Admin Interface Modes
|
||||||
|
|
||||||
WooNooW provides **three distinct admin interface modes** to accommodate different workflows and user preferences:
|
WooNooW provides **three distinct admin interface modes** to accommodate different workflows and user preferences:
|
||||||
|
|||||||
566
includes/Api/ProductsController.php
Normal file
566
includes/Api/ProductsController.php
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Products REST API Controller
|
||||||
|
*
|
||||||
|
* Handles CRUD operations for WooCommerce products
|
||||||
|
* Supports simple and variable products with comprehensive data
|
||||||
|
*
|
||||||
|
* @package WooNooW\Api
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Api;
|
||||||
|
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_Error;
|
||||||
|
use WC_Product;
|
||||||
|
use WC_Product_Simple;
|
||||||
|
use WC_Product_Variable;
|
||||||
|
use WC_Product_Variation;
|
||||||
|
|
||||||
|
class ProductsController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register REST API routes
|
||||||
|
*/
|
||||||
|
public static function register_routes() {
|
||||||
|
// List products
|
||||||
|
register_rest_route('woonoow/v1', '/products', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_products'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get single product
|
||||||
|
register_rest_route('woonoow/v1', '/products/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_product'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
register_rest_route('woonoow/v1', '/products', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'create_product'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update product
|
||||||
|
register_rest_route('woonoow/v1', '/products/(?P<id>\d+)', [
|
||||||
|
'methods' => 'PUT',
|
||||||
|
'callback' => [__CLASS__, 'update_product'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete product
|
||||||
|
register_rest_route('woonoow/v1', '/products/(?P<id>\d+)', [
|
||||||
|
'methods' => 'DELETE',
|
||||||
|
'callback' => [__CLASS__, 'delete_product'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get product categories
|
||||||
|
register_rest_route('woonoow/v1', '/products/categories', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_categories'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get product tags
|
||||||
|
register_rest_route('woonoow/v1', '/products/tags', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_tags'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get product attributes
|
||||||
|
register_rest_route('woonoow/v1', '/products/attributes', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_attributes'],
|
||||||
|
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get products list with filters
|
||||||
|
*/
|
||||||
|
public static function get_products(WP_REST_Request $request) {
|
||||||
|
$page = max(1, (int) $request->get_param('page'));
|
||||||
|
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
||||||
|
$search = $request->get_param('search');
|
||||||
|
$status = $request->get_param('status');
|
||||||
|
$category = $request->get_param('category');
|
||||||
|
$type = $request->get_param('type');
|
||||||
|
$stock_status = $request->get_param('stock_status');
|
||||||
|
$orderby = $request->get_param('orderby') ?: 'date';
|
||||||
|
$order = $request->get_param('order') ?: 'DESC';
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'post_type' => 'product',
|
||||||
|
'posts_per_page' => $per_page,
|
||||||
|
'paged' => $page,
|
||||||
|
'orderby' => $orderby,
|
||||||
|
'order' => $order,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if ($search) {
|
||||||
|
$args['s'] = sanitize_text_field($search);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if ($status) {
|
||||||
|
$args['post_status'] = $status;
|
||||||
|
} else {
|
||||||
|
$args['post_status'] = ['publish', 'draft', 'pending', 'private'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if ($category) {
|
||||||
|
$args['tax_query'] = [
|
||||||
|
[
|
||||||
|
'taxonomy' => 'product_cat',
|
||||||
|
'field' => 'term_id',
|
||||||
|
'terms' => (int) $category,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
if ($type) {
|
||||||
|
$args['tax_query'] = $args['tax_query'] ?? [];
|
||||||
|
$args['tax_query'][] = [
|
||||||
|
'taxonomy' => 'product_type',
|
||||||
|
'field' => 'slug',
|
||||||
|
'terms' => $type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stock status filter
|
||||||
|
if ($stock_status) {
|
||||||
|
$args['meta_query'] = [
|
||||||
|
[
|
||||||
|
'key' => '_stock_status',
|
||||||
|
'value' => $stock_status,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = new \WP_Query($args);
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
foreach ($query->posts as $post) {
|
||||||
|
$product = wc_get_product($post->ID);
|
||||||
|
if ($product) {
|
||||||
|
$products[] = self::format_product_list_item($product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'rows' => $products,
|
||||||
|
'total' => $query->found_posts,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $per_page,
|
||||||
|
'pages' => $query->max_num_pages,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single product
|
||||||
|
*/
|
||||||
|
public static function get_product(WP_REST_Request $request) {
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$product = wc_get_product($id);
|
||||||
|
|
||||||
|
if (!$product) {
|
||||||
|
return new WP_Error('product_not_found', __('Product not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(self::format_product_full($product), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new product
|
||||||
|
*/
|
||||||
|
public static function create_product(WP_REST_Request $request) {
|
||||||
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
|
// Determine product type
|
||||||
|
$type = $data['type'] ?? 'simple';
|
||||||
|
|
||||||
|
if ($type === 'variable') {
|
||||||
|
$product = new WC_Product_Variable();
|
||||||
|
} else {
|
||||||
|
$product = new WC_Product_Simple();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set basic data
|
||||||
|
$product->set_name($data['name'] ?? '');
|
||||||
|
$product->set_slug($data['slug'] ?? '');
|
||||||
|
$product->set_status($data['status'] ?? 'publish');
|
||||||
|
$product->set_description($data['description'] ?? '');
|
||||||
|
$product->set_short_description($data['short_description'] ?? '');
|
||||||
|
$product->set_sku($data['sku'] ?? '');
|
||||||
|
$product->set_regular_price($data['regular_price'] ?? '');
|
||||||
|
$product->set_sale_price($data['sale_price'] ?? '');
|
||||||
|
$product->set_manage_stock($data['manage_stock'] ?? false);
|
||||||
|
|
||||||
|
if ($data['manage_stock']) {
|
||||||
|
$product->set_stock_quantity($data['stock_quantity'] ?? 0);
|
||||||
|
$product->set_stock_status($data['stock_status'] ?? 'instock');
|
||||||
|
} else {
|
||||||
|
$product->set_stock_status($data['stock_status'] ?? 'instock');
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->set_weight($data['weight'] ?? '');
|
||||||
|
$product->set_length($data['length'] ?? '');
|
||||||
|
$product->set_width($data['width'] ?? '');
|
||||||
|
$product->set_height($data['height'] ?? '');
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
if (!empty($data['categories'])) {
|
||||||
|
$product->set_category_ids($data['categories']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (!empty($data['tags'])) {
|
||||||
|
$product->set_tag_ids($data['tags']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
if (!empty($data['image_id'])) {
|
||||||
|
$product->set_image_id($data['image_id']);
|
||||||
|
}
|
||||||
|
if (!empty($data['gallery_image_ids'])) {
|
||||||
|
$product->set_gallery_image_ids($data['gallery_image_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->save();
|
||||||
|
|
||||||
|
// Handle variations for variable products
|
||||||
|
if ($type === 'variable' && !empty($data['attributes'])) {
|
||||||
|
self::save_product_attributes($product, $data['attributes']);
|
||||||
|
|
||||||
|
if (!empty($data['variations'])) {
|
||||||
|
self::save_product_variations($product, $data['variations']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(self::format_product_full($product), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product
|
||||||
|
*/
|
||||||
|
public static function update_product(WP_REST_Request $request) {
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
|
$product = wc_get_product($id);
|
||||||
|
if (!$product) {
|
||||||
|
return new WP_Error('product_not_found', __('Product not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update basic data
|
||||||
|
if (isset($data['name'])) $product->set_name($data['name']);
|
||||||
|
if (isset($data['slug'])) $product->set_slug($data['slug']);
|
||||||
|
if (isset($data['status'])) $product->set_status($data['status']);
|
||||||
|
if (isset($data['description'])) $product->set_description($data['description']);
|
||||||
|
if (isset($data['short_description'])) $product->set_short_description($data['short_description']);
|
||||||
|
if (isset($data['sku'])) $product->set_sku($data['sku']);
|
||||||
|
if (isset($data['regular_price'])) $product->set_regular_price($data['regular_price']);
|
||||||
|
if (isset($data['sale_price'])) $product->set_sale_price($data['sale_price']);
|
||||||
|
|
||||||
|
if (isset($data['manage_stock'])) {
|
||||||
|
$product->set_manage_stock($data['manage_stock']);
|
||||||
|
if ($data['manage_stock']) {
|
||||||
|
if (isset($data['stock_quantity'])) $product->set_stock_quantity($data['stock_quantity']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['stock_status'])) $product->set_stock_status($data['stock_status']);
|
||||||
|
if (isset($data['weight'])) $product->set_weight($data['weight']);
|
||||||
|
if (isset($data['length'])) $product->set_length($data['length']);
|
||||||
|
if (isset($data['width'])) $product->set_width($data['width']);
|
||||||
|
if (isset($data['height'])) $product->set_height($data['height']);
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
if (isset($data['categories'])) {
|
||||||
|
$product->set_category_ids($data['categories']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (isset($data['tags'])) {
|
||||||
|
$product->set_tag_ids($data['tags']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
if (isset($data['image_id'])) {
|
||||||
|
$product->set_image_id($data['image_id']);
|
||||||
|
}
|
||||||
|
if (isset($data['gallery_image_ids'])) {
|
||||||
|
$product->set_gallery_image_ids($data['gallery_image_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->save();
|
||||||
|
|
||||||
|
// Handle variations for variable products
|
||||||
|
if ($product->is_type('variable')) {
|
||||||
|
if (isset($data['attributes'])) {
|
||||||
|
self::save_product_attributes($product, $data['attributes']);
|
||||||
|
}
|
||||||
|
if (isset($data['variations'])) {
|
||||||
|
self::save_product_variations($product, $data['variations']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(self::format_product_full($product), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete product
|
||||||
|
*/
|
||||||
|
public static function delete_product(WP_REST_Request $request) {
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$force = $request->get_param('force') === 'true';
|
||||||
|
|
||||||
|
$product = wc_get_product($id);
|
||||||
|
if (!$product) {
|
||||||
|
return new WP_Error('product_not_found', __('Product not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $product->delete($force);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('delete_failed', __('Failed to delete product', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true, 'id' => $id], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product categories
|
||||||
|
*/
|
||||||
|
public static function get_categories(WP_REST_Request $request) {
|
||||||
|
$terms = get_terms([
|
||||||
|
'taxonomy' => 'product_cat',
|
||||||
|
'hide_empty' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$categories = [];
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
$categories[] = [
|
||||||
|
'id' => $term->term_id,
|
||||||
|
'name' => $term->name,
|
||||||
|
'slug' => $term->slug,
|
||||||
|
'parent' => $term->parent,
|
||||||
|
'count' => $term->count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response($categories, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product tags
|
||||||
|
*/
|
||||||
|
public static function get_tags(WP_REST_Request $request) {
|
||||||
|
$terms = get_terms([
|
||||||
|
'taxonomy' => 'product_tag',
|
||||||
|
'hide_empty' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tags = [];
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
$tags[] = [
|
||||||
|
'id' => $term->term_id,
|
||||||
|
'name' => $term->name,
|
||||||
|
'slug' => $term->slug,
|
||||||
|
'count' => $term->count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response($tags, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product attributes
|
||||||
|
*/
|
||||||
|
public static function get_attributes(WP_REST_Request $request) {
|
||||||
|
$attributes = wc_get_attribute_taxonomies();
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($attributes as $attribute) {
|
||||||
|
$result[] = [
|
||||||
|
'id' => $attribute->attribute_id,
|
||||||
|
'name' => $attribute->attribute_name,
|
||||||
|
'label' => $attribute->attribute_label,
|
||||||
|
'type' => $attribute->attribute_type,
|
||||||
|
'orderby' => $attribute->attribute_orderby,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response($result, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format product for list view
|
||||||
|
*/
|
||||||
|
private static function format_product_list_item($product) {
|
||||||
|
$image = wp_get_attachment_image_src($product->get_image_id(), 'thumbnail');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $product->get_id(),
|
||||||
|
'name' => $product->get_name(),
|
||||||
|
'sku' => $product->get_sku(),
|
||||||
|
'type' => $product->get_type(),
|
||||||
|
'status' => $product->get_status(),
|
||||||
|
'price' => $product->get_price(),
|
||||||
|
'regular_price' => $product->get_regular_price(),
|
||||||
|
'sale_price' => $product->get_sale_price(),
|
||||||
|
'price_html' => $product->get_price_html(),
|
||||||
|
'stock_status' => $product->get_stock_status(),
|
||||||
|
'stock_quantity' => $product->get_stock_quantity(),
|
||||||
|
'manage_stock' => $product->get_manage_stock(),
|
||||||
|
'image_url' => $image ? $image[0] : '',
|
||||||
|
'permalink' => get_permalink($product->get_id()),
|
||||||
|
'date_created' => $product->get_date_created() ? $product->get_date_created()->date('Y-m-d H:i:s') : '',
|
||||||
|
'date_modified' => $product->get_date_modified() ? $product->get_date_modified()->date('Y-m-d H:i:s') : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format product with full details
|
||||||
|
*/
|
||||||
|
private static function format_product_full($product) {
|
||||||
|
$data = self::format_product_list_item($product);
|
||||||
|
|
||||||
|
// Add full details
|
||||||
|
$data['description'] = $product->get_description();
|
||||||
|
$data['short_description'] = $product->get_short_description();
|
||||||
|
$data['slug'] = $product->get_slug();
|
||||||
|
$data['weight'] = $product->get_weight();
|
||||||
|
$data['length'] = $product->get_length();
|
||||||
|
$data['width'] = $product->get_width();
|
||||||
|
$data['height'] = $product->get_height();
|
||||||
|
$data['categories'] = $product->get_category_ids();
|
||||||
|
$data['tags'] = $product->get_tag_ids();
|
||||||
|
$data['image_id'] = $product->get_image_id();
|
||||||
|
$data['gallery_image_ids'] = $product->get_gallery_image_ids();
|
||||||
|
|
||||||
|
// Gallery images
|
||||||
|
$gallery = [];
|
||||||
|
foreach ($product->get_gallery_image_ids() as $image_id) {
|
||||||
|
$image = wp_get_attachment_image_src($image_id, 'full');
|
||||||
|
if ($image) {
|
||||||
|
$gallery[] = [
|
||||||
|
'id' => $image_id,
|
||||||
|
'url' => $image[0],
|
||||||
|
'width' => $image[1],
|
||||||
|
'height' => $image[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$data['gallery'] = $gallery;
|
||||||
|
|
||||||
|
// Variable product specifics
|
||||||
|
if ($product->is_type('variable')) {
|
||||||
|
$data['attributes'] = self::get_product_attributes($product);
|
||||||
|
$data['variations'] = self::get_product_variations($product);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product attributes
|
||||||
|
*/
|
||||||
|
private static function get_product_attributes($product) {
|
||||||
|
$attributes = [];
|
||||||
|
foreach ($product->get_attributes() as $attribute) {
|
||||||
|
$attributes[] = [
|
||||||
|
'id' => $attribute->get_id(),
|
||||||
|
'name' => $attribute->get_name(),
|
||||||
|
'options' => $attribute->get_options(),
|
||||||
|
'position' => $attribute->get_position(),
|
||||||
|
'visible' => $attribute->get_visible(),
|
||||||
|
'variation' => $attribute->get_variation(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product variations
|
||||||
|
*/
|
||||||
|
private static function get_product_variations($product) {
|
||||||
|
$variations = [];
|
||||||
|
foreach ($product->get_children() as $variation_id) {
|
||||||
|
$variation = wc_get_product($variation_id);
|
||||||
|
if ($variation) {
|
||||||
|
$image = wp_get_attachment_image_src($variation->get_image_id(), 'thumbnail');
|
||||||
|
$variations[] = [
|
||||||
|
'id' => $variation->get_id(),
|
||||||
|
'sku' => $variation->get_sku(),
|
||||||
|
'price' => $variation->get_price(),
|
||||||
|
'regular_price' => $variation->get_regular_price(),
|
||||||
|
'sale_price' => $variation->get_sale_price(),
|
||||||
|
'stock_status' => $variation->get_stock_status(),
|
||||||
|
'stock_quantity' => $variation->get_stock_quantity(),
|
||||||
|
'manage_stock' => $variation->get_manage_stock(),
|
||||||
|
'attributes' => $variation->get_attributes(),
|
||||||
|
'image_id' => $variation->get_image_id(),
|
||||||
|
'image_url' => $image ? $image[0] : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $variations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save product attributes
|
||||||
|
*/
|
||||||
|
private static function save_product_attributes($product, $attributes_data) {
|
||||||
|
$attributes = [];
|
||||||
|
foreach ($attributes_data as $attr_data) {
|
||||||
|
$attribute = new \WC_Product_Attribute();
|
||||||
|
$attribute->set_name($attr_data['name']);
|
||||||
|
$attribute->set_options($attr_data['options']);
|
||||||
|
$attribute->set_position($attr_data['position'] ?? 0);
|
||||||
|
$attribute->set_visible($attr_data['visible'] ?? true);
|
||||||
|
$attribute->set_variation($attr_data['variation'] ?? true);
|
||||||
|
$attributes[] = $attribute;
|
||||||
|
}
|
||||||
|
$product->set_attributes($attributes);
|
||||||
|
$product->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save product variations
|
||||||
|
*/
|
||||||
|
private static function save_product_variations($product, $variations_data) {
|
||||||
|
foreach ($variations_data as $var_data) {
|
||||||
|
if (isset($var_data['id']) && $var_data['id']) {
|
||||||
|
// Update existing variation
|
||||||
|
$variation = wc_get_product($var_data['id']);
|
||||||
|
} else {
|
||||||
|
// Create new variation
|
||||||
|
$variation = new WC_Product_Variation();
|
||||||
|
$variation->set_parent_id($product->get_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variation) {
|
||||||
|
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
||||||
|
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']);
|
||||||
|
if (isset($var_data['sale_price'])) $variation->set_sale_price($var_data['sale_price']);
|
||||||
|
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
|
||||||
|
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
|
||||||
|
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
|
||||||
|
if (isset($var_data['attributes'])) $variation->set_attributes($var_data['attributes']);
|
||||||
|
if (isset($var_data['image_id'])) $variation->set_image_id($var_data['image_id']);
|
||||||
|
|
||||||
|
$variation->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use WooNooW\Api\DeveloperController;
|
|||||||
use WooNooW\Api\SystemController;
|
use WooNooW\Api\SystemController;
|
||||||
use WooNooW\Api\NotificationsController;
|
use WooNooW\Api\NotificationsController;
|
||||||
use WooNooW\Api\ActivityLogController;
|
use WooNooW\Api\ActivityLogController;
|
||||||
|
use WooNooW\Api\ProductsController;
|
||||||
|
|
||||||
class Routes {
|
class Routes {
|
||||||
public static function init() {
|
public static function init() {
|
||||||
@@ -89,6 +90,9 @@ class Routes {
|
|||||||
// Activity Log controller
|
// Activity Log controller
|
||||||
$activity_log_controller = new ActivityLogController();
|
$activity_log_controller = new ActivityLogController();
|
||||||
$activity_log_controller->register_routes();
|
$activity_log_controller->register_routes();
|
||||||
|
|
||||||
|
// Products controller
|
||||||
|
ProductsController::register_routes();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user