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
|
||||
|
||||
**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 `<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
|
||||
|
||||
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\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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user