diff --git a/PROGRESS_NOTE.md b/PROGRESS_NOTE.md
index 6b75051..3ef57c8 100644
--- a/PROGRESS_NOTE.md
+++ b/PROGRESS_NOTE.md
@@ -1,6 +1,6 @@
# 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
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
**Status:** β
Production Ready
**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 `
- ` 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
diff --git a/PROJECT_SOP.md b/PROJECT_SOP.md
index b162523..54bf477 100644
--- a/PROJECT_SOP.md
+++ b/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
+ 'GET',
+ 'callback' => [__CLASS__, 'get_{module}'],
+ 'permission_callback' => [Permissions::class, 'check_admin'],
+ ]);
+
+ // Single
+ register_rest_route('woonoow/v1', '/{module}/(?P\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\d+)', [
+ 'methods' => 'PUT',
+ 'callback' => [__CLASS__, 'update_{item}'],
+ 'permission_callback' => [Permissions::class, 'check_admin'],
+ ]);
+
+ // Delete
+ register_rest_route('woonoow/v1', '/{module}/(?P\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(initial.status || undefined);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedIds, setSelectedIds] = useState([]);
+ 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 (
+
+ {/* Desktop: Filters */}
+
+ {/* Filter controls */}
+
+
+ {/* Mobile: Search + Filter */}
+
+ setFilterSheetOpen(true)}
+ />
+
+
+ {/* Desktop: Table */}
+
+
+
+
+ |
+ {__('Name')} |
+ {__('Status')} |
+ {__('Actions')} |
+
+
+
+ {filteredItems.map(item => (
+
+ | toggleRow(item.id)} /> |
+ {item.name} |
+ |
+ {__('View')} |
+
+ ))}
+
+
+
+
+ {/* Mobile: Cards */}
+
+ {filteredItems.map(item => (
+ <{Module}Card key={item.id} item={item} />
+ ))}
+
+
+ {/* Delete Dialog */}
+
+ {/* Dialog content */}
+
+
+ );
+}
+```
+
+### π 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(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 = (
+
+
+
+
+ );
+ setPageHeader(__('New {Item}'), actions);
+ return () => clearPageHeader();
+ }, [mutate.isPending, setPageHeader, clearPageHeader, nav]);
+
+ return (
+
+ <{Module}Form
+ mode="create"
+ formRef={formRef}
+ hideSubmitButton={true}
+ onSubmit={(form) => mutate.mutate(form)}
+ />
+
+ );
+}
+```
+
+### βοΈ 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(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 = (
+
+
+
+
+ );
+ setPageHeader(__('Edit {Item}'), actions);
+ return () => clearPageHeader();
+ }, [itemId, upd.isPending, setPageHeader, clearPageHeader, nav]);
+
+ if (!Number.isFinite(itemId)) {
+ return {__('Invalid ID')}
;
+ }
+
+ if (itemQ.isLoading) {
+ return ;
+ }
+
+ if (itemQ.isError) {
+ return itemQ.refetch()}
+ />;
+ }
+
+ return (
+
+ <{Module}Form
+ mode="edit"
+ initial={item}
+ formRef={formRef}
+ hideSubmitButton={true}
+ onSubmit={(form) => upd.mutate(form)}
+ />
+
+ );
+}
+```
+
+### π 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
WooNooW provides **three distinct admin interface modes** to accommodate different workflows and user preferences:
diff --git a/includes/Api/ProductsController.php b/includes/Api/ProductsController.php
new file mode 100644
index 0000000..7febdcb
--- /dev/null
+++ b/includes/Api/ProductsController.php
@@ -0,0 +1,566 @@
+ 'GET',
+ 'callback' => [__CLASS__, 'get_products'],
+ 'permission_callback' => [Permissions::class, 'check_admin'],
+ ]);
+
+ // Get single product
+ register_rest_route('woonoow/v1', '/products/(?P\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\d+)', [
+ 'methods' => 'PUT',
+ 'callback' => [__CLASS__, 'update_product'],
+ 'permission_callback' => [Permissions::class, 'check_admin'],
+ ]);
+
+ // Delete product
+ register_rest_route('woonoow/v1', '/products/(?P\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();
+ }
+ }
+ }
+}
diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php
index d902f4d..59a209e 100644
--- a/includes/Api/Routes.php
+++ b/includes/Api/Routes.php
@@ -17,6 +17,7 @@ use WooNooW\Api\DeveloperController;
use WooNooW\Api\SystemController;
use WooNooW\Api\NotificationsController;
use WooNooW\Api\ActivityLogController;
+use WooNooW\Api\ProductsController;
class Routes {
public static function init() {
@@ -89,6 +90,9 @@ class Routes {
// Activity Log controller
$activity_log_controller = new ActivityLogController();
$activity_log_controller->register_routes();
+
+ // Products controller
+ ProductsController::register_routes();
});
}
}