Files
WooNooW/PROJECT_SOP.md
Dwindi Ramadhana f397ef850f feat: Add product images support with WP Media Library integration
- Add WP Media Library integration for product and variation images
- Support images array (URLs) conversion to attachment IDs
- Add images array to API responses (Admin & Customer SPA)
- Implement drag-and-drop sortable images in Admin product form
- Add image gallery thumbnails in Customer SPA product page
- Initialize WooCommerce session for guest cart operations
- Fix product variations and attributes display in Customer SPA
- Add variation image field in Admin SPA

Changes:
- includes/Api/ProductsController.php: Handle images array, add to responses
- includes/Frontend/ShopController.php: Add images array for customer SPA
- includes/Frontend/CartController.php: Initialize WC session for guests
- admin-spa/src/lib/wp-media.ts: Add openWPMediaGallery function
- admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx: WP Media + sortable images
- admin-spa/src/routes/Products/partials/tabs/VariationsTab.tsx: Add variation image field
- customer-spa/src/pages/Product/index.tsx: Add gallery thumbnails display
2025-11-26 16:18:43 +07:00

71 KiB
Raw Blame History

🧭 WooNooW — Single Source of Truth (S.O.P.)

This document defines the Standard Operating Procedure for developing, maintaining, and collaborating on the WooNooW project — ensuring every AI Agent or human collaborator follows the same workflow and conventions.


1. 🎯 Project Intent

WooNooW modernizes WooCommerce without migration, delivering a Hybrid + SPA experience for both storefront and admin, while keeping compatibility with legacy WooCommerce addons.

Goal: “Reimagine WooCommerce for now — faster, modern, reversible.”


1.1 📝 Documentation Standards

Progress & Testing Documentation

All progress notes and reports MUST be added to:

  • PROGRESS_NOTE.md - Consolidated progress tracking with timestamps

All test checklists MUST be added to:

  • TESTING_CHECKLIST.md - Comprehensive testing requirements

Feature-specific documentation:

  • Create dedicated .md files for major features (e.g., PAYMENT_GATEWAY_INTEGRATION.md)
  • Link to these files from PROGRESS_NOTE.md
  • Include implementation details, code examples, and testing steps

API Routes documentation:

  • API_ROUTES.md - Complete registry of all REST API routes
  • MUST be updated when adding new API endpoints
  • Prevents route conflicts between modules
  • Documents ownership and naming conventions

Metabox & Custom Fields compatibility:

  • METABOX_COMPAT.md - 🔴 CRITICAL compatibility requirement
  • Documents how to expose WordPress/WooCommerce metaboxes in SPA
  • Currently NOT implemented - blocks production readiness
  • Required for third-party plugin compatibility (Shipment Tracking, ACF, etc.)

Documentation Rules:

  1. Update PROGRESS_NOTE.md after completing any major feature
  2. Add test cases to TESTING_CHECKLIST.md before implementation
  3. Use consistent formatting (emojis, headings, code blocks)
  4. Include "Last synced" timestamp in GMT+7
  5. Reference file paths and line numbers for code changes

2. 🧱 Core Principles

  1. Zero Data Migration — All data remains in WooCommerces database schema.
  2. Safe Activation/Deactivation — Deactivating WooNooW restores vanilla Woo instantly.
  3. HPOS-First Architecture — Mandatory use of WooCommerce HPOS.
  4. Hybrid by Default — SSR + React SPA islands for Cart, Checkout, and MyAccount.
  5. Full SPA Option — Optional React-only mode for performance-critical sites.
  6. Compat Layer — HookBridge & SlotRenderer preserve legacy addon behavior.
  7. Async System — MailQueue & async actions replace blocking PHP tasks.

3. ⚙️ Tech Stack Reference

Layer Technology
Backend PHP 8.2+, WordPress, WooCommerce (HPOS), Action Scheduler
Frontend React 18 + TypeScript, Vite, React Query, Tailwind CSS + Shadcn UI, Recharts
Architecture Modular PSR4 autoload, RESTdriven logic, SPA hydration islands
Routing Admin SPA: HashRouter, Customer SPA: HashRouter
Build Composer + NPM + ESM scripts
Packaging scripts/package-zip.mjs
Deployment LocalWP for dev, Coolify for staging

3.1 🔀 Customer SPA Routing Pattern

HashRouter Implementation

Why HashRouter?

The Customer SPA uses HashRouter instead of BrowserRouter to avoid conflicts with WordPress routing:

// customer-spa/src/App.tsx
import { HashRouter } from 'react-router-dom';

<HashRouter>
  <Routes>
    <Route path="/product/:slug" element={<Product />} />
    <Route path="/cart" element={<Cart />} />
    {/* ... */}
  </Routes>
</HashRouter>

URL Format:

Shop:     https://example.com/shop#/
Product:  https://example.com/shop#/product/product-slug
Cart:     https://example.com/shop#/cart
Checkout: https://example.com/shop#/checkout
Account:  https://example.com/shop#/my-account

How It Works:

  1. WordPress loads: /shop (valid WordPress page)
  2. React takes over: #/product/product-slug (client-side only)
  3. No conflicts: Everything after # is invisible to WordPress

Benefits:

Benefit Description
Zero WordPress conflicts WordPress never sees routes after #
Direct URL access Works from any source (email, social, QR codes)
Shareable links Perfect for marketing campaigns
No server config No .htaccess or rewrite rules needed
Reliable No canonical redirects or 404 issues
Consistent with Admin SPA Same routing approach

Use Cases:

Email campaigns: https://example.com/shop#/product/special-offer Social media: Share product links directly QR codes: Generate codes for products Bookmarks: Users can bookmark product pages Direct access: Type URL in browser

Implementation Rules:

  1. Always use HashRouter for Customer SPA
  2. Use React Router Link components (automatically use hash URLs)
  3. Test direct URL access for all routes
  4. Document URL format in user guides
  5. Never use BrowserRouter (causes WordPress conflicts)
  6. Never try to override WordPress routes (unreliable)

Comparison: BrowserRouter vs HashRouter

Feature BrowserRouter HashRouter
URL Format /product/slug #/product/slug
Clean URLs Yes Has #
SEO Better ⚠️ Acceptable
Direct Access Conflicts Works
WordPress Conflicts Many None
Sharing Unreliable Reliable
Email Links Breaks Works
Setup Complexity Complex Simple
Reliability Fragile Solid

Winner: HashRouter for Customer SPA

SEO Considerations:

  • WooCommerce product pages still exist for SEO
  • Search engines index actual product URLs
  • SPA provides better UX for users
  • Canonical tags point to real products
  • Best of both worlds approach

Files:

  • customer-spa/src/App.tsx - HashRouter configuration
  • customer-spa/src/pages/* - All page components use React Router

4. 🧩 Folder Structure

woonoow/
├─ woonoow.php              # main plugin file (WordPress entry)
├─ includes/                # PSR4 classes
│  ├─ Core/                 # Bootstrap, Datastores, Mail, Hooks
│  ├─ Api/                  # REST endpoints
│  ├─ Admin/                # Menus, asset loaders
│  ├─ Compat/               # Compatibility shims & hook mirrors
│  └─ …
├─ admin-spa/               # React admin interface
├─ customer-spa/            # React customer interface
├─ scripts/                 # automation scripts
│  └─ package-zip.mjs
├─ dist/                    # build output
├─ composer.json
├─ package.json
├─ README.md
└─ PROJECT_SOP.md           # this file

5. 🧰 Development Workflow

5.1 Environment Setup

  1. Use LocalWP or Docker (PHP 8.2+, MySQL 8, Redis optional).
  2. Clone or mount woonoow folder into /wp-content/plugins/.
  3. Ensure WooCommerce is installed and active.
  4. Activate WooNooW in wp-admin → “Plugins.”

5.2 Build & Test Commands

npm run build       # build both admin & customer SPAs
npm run pack        # create woonoow.zip for release
composer dump-autoload

5.3 Plugin Packaging

  • The release ZIP must contain only:
    woonoow.php
    includes/
    admin-spa/dist/
    customer-spa/dist/
    composer.json
    package.json
    phpcs.xml
    README.md
    
  • Build ZIP using:
    node scripts/package-zip.mjs
    

5.4 Commit Convention

Use conventional commits:

feat(api): add checkout quote endpoint
fix(core): prevent duplicate email send on async queue
refactor(admin): improve SPA routing

5.5 Branching

  • main — stable, production-ready
  • dev — development staging
  • feature/* — specific features or fixes

5.6 Admin SPA Template Pattern

The WooNooW Admin SPA follows a consistent layout structure ensuring a predictable UI across all routes:

Structure

Admin-SPA
├── App Bar [Branding | Version | Server Connectivity | Global Buttons (Fullscreen)]
├── Menu Bar (Main Menu) [Normal (Tabbed Overflow-X-Auto)] [Fullscreen (Sidebar)]
├── Submenu Bar (Tabbed Overflow-X-Auto, context-sensitive)
└── Page Template
    ├── Page Tool Bar (Page filters, CRUD buttons, Back button)
    └── Page Content (Data tables, cards, forms)

Behavioral Notes

  • App Bar: Persistent across all routes; contains global controls (fullscreen, server, user menu).
  • Menu Bar: Primary navigation for main sections (Dashboard, Orders, Products, etc.); sticky with overflow-x scroll.
  • Submenu Bar: Context-sensitive secondary tabs under the main menu.
  • Page Tool Bar: Contains functional filters and actions relevant to the current page.
  • Page Content: Hosts the page body—tables, analytics, and CRUD forms.
  • In Fullscreen mode, Menu Bar becomes a collapsible sidebar while all others remain visible.
  • Sticky layout rules ensure App Bar and Menu Bar remain fixed while content scrolls independently.

5.7 CRUD Module Pattern (Standard Operating Procedure)

WooNooW enforces a consistent CRUD pattern for all entity management modules (Orders, Products, Customers, etc.) to ensure predictable UX and maintainability.

Core Principle: All CRUD modules MUST follow the submenu tab pattern with consistent toolbar structure.

UI Structure

Submenu Tabs Pattern:

[All {Entity}] [New] [Categories] [Tags] [Other Sections...]

Toolbar Structure:

[Bulk Actions] [Filters...] [Search]

Examples:

  • Products: All products | New | Categories | Tags | Attributes
  • Orders: All orders | New | Drafts | Recurring
  • Customers: All customers | New | Groups | Segments

Implementation Rules

  1. Use Submenu Tabs for main sections

    • Primary action (New) is a tab, NOT a toolbar button
    • Tabs for related entities (Categories, Tags, etc.)
    • Consistent with WordPress/WooCommerce patterns
  2. Toolbar for Actions & Filters

    • Bulk actions (Delete, Export, etc.)
    • Filter dropdowns (Status, Type, Date, etc.)
    • Search input
    • NO primary CRUD buttons (New, Edit, etc.)
  3. Don't Mix Patterns

    • Don't put "New" button in toolbar if using submenu
    • Don't duplicate actions in both toolbar and submenu
    • Don't use different patterns for different modules

Why This Pattern?

Industry Standard:

  • Shopify Admin uses submenu tabs
  • WooCommerce uses submenu tabs
  • WordPress core uses submenu tabs

Benefits:

  • Scalability: Easy to add new sections
  • Consistency: Users know where to find actions
  • Clarity: Visual hierarchy between main actions and filters

Migration Checklist

When updating an existing module to follow this pattern:

  • Move "New {Entity}" button from toolbar to submenu tab
  • Add other relevant tabs (Drafts, Categories, etc.)
  • Keep filters and bulk actions in toolbar
  • Update navigation tree in NavigationRegistry.php
  • Test mobile responsiveness (tabs scroll horizontally)

Code Example

Navigation Tree (PHP):

'orders' => [
    'label' => __('Orders', 'woonoow'),
    'path' => '/orders',
    'icon' => 'ShoppingCart',
    'children' => [
        'all' => [
            'label' => __('All orders', 'woonoow'),
            'path' => '/orders',
        ],
        'new' => [
            'label' => __('New', 'woonoow'),
            'path' => '/orders/new',
        ],
        'drafts' => [
            'label' => __('Drafts', 'woonoow'),
            'path' => '/orders/drafts',
        ],
    ],
],

Submenu Component (React):

<SubMenu>
  <SubMenuItem to="/orders" label={__('All orders')} />
  <SubMenuItem to="/orders/new" label={__('New')} />
  <SubMenuItem to="/orders/drafts" label={__('Drafts')} />
</SubMenu>

Submenu Mobile Behavior:

To reduce clutter on mobile detail/new/edit pages, submenu MUST be hidden on mobile for these pages:

// In SubmenuBar.tsx
const isDetailPage = /\/(orders|products|coupons|customers)\/(?:new|\d+(?:\/edit)?)$/.test(pathname);
const hiddenOnMobile = isDetailPage ? 'hidden md:block' : '';

return (
  <div className={`border-b border-border bg-background ${hiddenOnMobile}`}>
    {/* Submenu items */}
  </div>
);

Rules:

  1. Hide on mobile for detail/new/edit pages (has own tabs + back button)
  2. Show on desktop for all pages (useful for quick navigation)
  3. Show on mobile for index pages only (list views)
  4. Use regex pattern to detect detail/new/edit pages
  5. Never hide on desktop - always useful for navigation
  6. Never show on mobile detail pages - causes clutter

Behavior Matrix:

Page Type Mobile Desktop Reason
Index (/orders) Show Show Main navigation
New (/orders/new) Hide Show Has form tabs + back button
Edit (/orders/123/edit) Hide Show Has form tabs + back button
Detail (/orders/123) Hide Show Has detail tabs + back button

Toolbar (React):

<Toolbar>
  <BulkActions />
  <FilterDropdown options={statusOptions} />
  <SearchInput />
</Toolbar>

Toolbar Button Standards

All CRUD list pages MUST use consistent button styling in the toolbar:

Button Types:

Button Type Classes Use Case
Delete (Destructive) border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2 Bulk delete action
Refresh (Required) border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2 Refresh data (MUST exist in all CRUD lists)
Reset Filters text-sm text-muted-foreground hover:text-foreground underline Clear all active filters
Export/Secondary border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2 Other secondary actions

Button Structure:

<button 
  className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
  onClick={handleAction}
  disabled={condition}
>
  <IconComponent className="w-4 h-4" />
  {__('Button Label')}
</button>

Rules:

  1. Delete button - Always use bg-red-600 (NOT bg-black)
  2. Refresh button - MUST exist in all CRUD list pages (mandatory)
  3. Reset filters - Use text link style (NOT button with background)
  4. Icon placement - Use inline-flex items-center gap-2 (NOT inline mr-2)
  5. Destructive actions - Only show when items selected (conditional render)
  6. Non-destructive actions - Can be always visible (use disabled state)
  7. Consistent spacing - Use gap-2 between icon and text
  8. Hover states - Destructive: hover:bg-red-700, Secondary: hover:bg-accent
  9. Never use bg-black for delete buttons
  10. Never use inline mr-2 - use inline-flex gap-2 instead
  11. Never use button style for reset filters - use text link

Toolbar Layout:

<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
  {/* Left: Bulk Actions */}
  <div className="flex gap-3">
    {/* Delete - Show only when items selected */}
    {selectedIds.length > 0 && (
      <button className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2">
        <Trash2 className="w-4 h-4" />
        {__('Delete')} ({selectedIds.length})
      </button>
    )}
    
    {/* Refresh - Always visible (REQUIRED) */}
    <button className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2">
      <RefreshCw className="w-4 h-4" />
      {__('Refresh')}
    </button>
  </div>
  
  {/* Right: Filters */}
  <div className="flex gap-3 flex-wrap items-center">
    <Select>...</Select>
    <Select>...</Select>
    
    {/* Reset Filters - Text link style */}
    {activeFiltersCount > 0 && (
      <button className="text-sm text-muted-foreground hover:text-foreground underline">
        {__('Clear filters')}
      </button>
    )}
  </div>
</div>

Table/List UI Standards

All CRUD list pages MUST follow these consistent UI patterns:

Table Structure:

<div className="hidden md:block rounded-lg border overflow-hidden">
  <table className="w-full">
    <thead className="bg-muted/50">
      <tr className="border-b">
        <th className="w-12 p-3">{/* Checkbox */}</th>
        <th className="text-left p-3 font-medium">{__('Column')}</th>
        {/* ... more columns */}
      </tr>
    </thead>
    <tbody>
      <tr className="border-b hover:bg-muted/30 last:border-0">
        <td className="p-3">{/* Cell content */}</td>
        {/* ... more cells */}
      </tr>
    </tbody>
  </table>
</div>

Required Classes:

Element Classes Purpose
Container rounded-lg border overflow-hidden Rounded corners, border, hide overflow
Table w-full Full width
Header Row bg-muted/50 + border-b Light background, bottom border
Header Cell p-3 font-medium text-left Padding, bold, left-aligned
Body Row border-b hover:bg-muted/30 last:border-0 Border, hover effect, remove last border
Body Cell p-3 Consistent padding (NOT px-3 py-2)
Checkbox Column w-12 p-3 Fixed width for checkbox
Actions Column text-right p-3 or text-center p-3 Right/center aligned

Empty State Pattern:

<tr>
  <td colSpan={columnCount} className="p-8 text-center text-muted-foreground">
    <IconComponent className="w-12 h-12 mx-auto mb-2 opacity-50" />
    {primaryMessage}
    {helperText && <p className="text-sm mt-1">{helperText}</p>}
  </td>
</tr>

Mobile Card Pattern (Linkable):

Mobile cards MUST be fully tappable (whole card is a link) for better UX:

<div className="md:hidden space-y-3">
  {items.map(item => (
    <Link
      key={item.id}
      to={`/entity/${item.id}/edit`}
      className="block bg-card border border-border rounded-xl p-3 hover:bg-accent/50 transition-colors active:scale-[0.98] active:transition-transform shadow-sm"
    >
      <div className="flex items-center gap-3">
        {/* Checkbox with stopPropagation */}
        <div onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelect(item.id); }}>
          <Checkbox checked={selected} className="w-5 h-5" />
        </div>

        {/* Content */}
        <div className="flex-1 min-w-0">
          <h3 className="font-bold text-base leading-tight mb-1">{item.name}</h3>
          <div className="text-sm text-muted-foreground truncate mb-2">{item.description}</div>
          <div className="flex items-center gap-3 text-xs text-muted-foreground mb-1">
            <span>{item.stats}</span>
          </div>
          <div className="font-bold text-lg tabular-nums text-primary">{item.amount}</div>
        </div>

        {/* Chevron */}
        <ChevronRight className="w-5 h-5 text-muted-foreground flex-shrink-0" />
      </div>
    </Link>
  ))}
</div>

Card Rules:

  1. Whole card is Link - Better mobile UX (single tap to view/edit)
  2. Use space-y-3 - Consistent spacing between cards
  3. Checkbox stopPropagation - Prevent navigation when selecting
  4. ChevronRight icon - Visual indicator card is tappable
  5. Active scale animation - active:scale-[0.98] for tap feedback
  6. Hover effect - hover:bg-accent/50 for desktop hover
  7. Shadow - shadow-sm for depth
  8. Rounded corners - rounded-xl for modern look
  9. Never use separate edit button - Whole card should be tappable
  10. Never use space-y-2 - Use space-y-3 for consistency

Table Rules:

  1. Always use p-3 for table cells (NOT px-3 py-2)
  2. Always add hover:bg-muted/30 to body rows
  3. Always use bg-muted/50 for table headers
  4. Always use font-medium for header cells
  5. Always use last:border-0 to remove last row border
  6. Always use overflow-hidden on table container
  7. Never mix padding styles between modules
  8. Never omit hover effects on interactive rows

Responsive Behavior:

  • Desktop: Show table with hidden md:block
  • Mobile: Show cards with md:hidden
  • Both views must support same actions (select, edit, delete)
  • Cards must be linkable (whole card tappable)

Variable Product Handling in Order Forms

When adding products to orders, variable products MUST follow the Tokopedia/Shopee pattern:

Responsive Modal Pattern:

  • Desktop: Use Dialog component (centered modal)
  • Mobile: Use Drawer component (bottom sheet)
  • Detection: Use useMediaQuery("(min-width: 768px)")

Implementation:

const isDesktop = useMediaQuery("(min-width: 768px)");

{/* Desktop: Dialog */}
{selectedProduct && isDesktop && (
  <Dialog open={open} onOpenChange={setOpen}>
    <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
      <DialogHeader>
        <DialogTitle>{product.name}</DialogTitle>
      </DialogHeader>
      {/* Variation list */}
    </DialogContent>
  </Dialog>
)}

{/* Mobile: Drawer */}
{selectedProduct && !isDesktop && (
  <Drawer open={open} onOpenChange={setOpen}>
    <DrawerContent>
      <DrawerHeader>
        <DrawerTitle>{product.name}</DrawerTitle>
      </DrawerHeader>
      {/* Variation list */}
    </DrawerContent>
  </Drawer>
)}

Desktop Pattern:

[Search Product...]
↓
[Product Name - Variable Product]
  └─ [Select Variation ▼] → Dropdown: Red, Blue, Green
     [Add to Order]

Mobile Pattern:

[Search Product...]
↓
[Product Card]
  Product Name
  [Select Variation →] → Opens drawer with variation chips
  [Add]

Cart Display (Each variation = separate row):

✓ Anker Earbuds
  White                    Rp296,000  [-] 1 [+] [🗑️]

✓ Anker Earbuds  
  Black                    Rp296,000  [-] 1 [+] [🗑️]

**Rules:**
1. ✅ Each variation is a **separate line item**
2. ✅ Show variation name clearly next to product name
3. ✅ Allow adding same product multiple times with different variations
4. ✅ Mobile: Click variation to open drawer for selection
5. ❌ Don't auto-select first variation
6. ❌ Don't hide variation selector
7. ✅ **Duplicate Handling**: Same product + same variation = increment quantity (NOT new row)
8. ✅ **Empty Attribute Values**: Filter empty attribute values - Use `.filter()` to remove empty strings

**Implementation:**
- Product search shows variable products
- If variable, show variation selector (dropdown/drawer)
- User must select variation before adding
- Each selected variation becomes separate cart item
- Can repeat for different variations

### 5.8 Mobile Responsiveness & UI Controls

WooNooW enforces a mobilefirst responsive standard across all SPA interfaces to ensure usability on small screens.

**Control Sizing Standard (`.ui-ctrl`)**
- All interactive controls — input, select, button, and dropdown options — must include the `.ui-ctrl` class or equivalent utility for consistent sizing.
- Default height: `h-11` (mobile), `md:h-9` (desktop).
- This sizing improves tap area accessibility and maintains visual alignment between mobile and desktop.

**Responsive Layout Rules**
- On mobile view, even in fullscreen mode, the layout uses **Topbar navigation** instead of Sidebar for better reachability.
- The Sidebar layout is applied **only** in desktop fullscreen mode.
- Sticky top layers (`App Bar`, `Menu Bar`) remain visible while subcontent scrolls independently.
- Tables and grids must support horizontal scroll (`overflow-x-auto`) and collapse to cards when screen width < 640px.

**Tokens & Global Styles**
- File: `admin-spa/src/ui/tokens.css` defines base CSS variables for control sizing.
- File: `admin-spa/src/index.css` imports `./ui/tokens.css` and applies the `.ui-ctrl` rules globally.

These rules ensure consistent UX across device classes while maintaining WooNooW's design hierarchy.

### 5.8 Dialog Behavior Pattern

WooNooW uses **Radix UI Dialog** with specific patterns for preventing accidental dismissal.

**Core Principle:** Prevent outside-click and escape-key dismissal for dialogs with unsaved changes or complex editing.

**Dialog Types:**

| Type | Outside Click | Escape Key | Use Case | Example |
|------|---------------|------------|----------|---------|
| **Informational** | ✅ Allow | ✅ Allow | Simple info, confirmations | Alert dialogs |
| **Quick Edit** | ✅ Allow | ✅ Allow | Single field edits | Rename, quick settings |
| **Heavy Edit** | ❌ Prevent | ❌ Prevent | Multi-field forms, rich content | Email builder, template editor |
| **Destructive** | ❌ Prevent | ❌ Prevent | Delete confirmations with input | Delete with confirmation text |

**Implementation:**

```typescript
// Heavy Edit Dialog - Prevent accidental dismissal
<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent
    onInteractOutside={(e) => e.preventDefault()}
    onEscapeKeyDown={(e) => e.preventDefault()}
  >
    {/* Dialog content */}
    <DialogFooter>
      <Button variant="outline" onClick={() => setIsOpen(false)}>
        {__('Cancel')}
      </Button>
      <Button onClick={handleSave}>
        {__('Save Changes')}
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

// Quick Edit Dialog - Allow dismissal
<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent>
    {/* Simple content */}
  </DialogContent>
</Dialog>

Rules:

  1. Prevent dismissal when:

    • Dialog contains unsaved form data
    • User is editing rich content (WYSIWYG, code editor)
    • Dialog has multiple steps or complex state
    • Action is destructive and requires confirmation
  2. Allow dismissal when:

    • Dialog is purely informational
    • Single field with auto-save
    • No data loss risk
    • Quick actions (view, select)
  3. Always provide explicit close buttons:

    • Cancel button to close without saving
    • Save button to commit changes
    • X button in header (Radix default)

Examples:

  • Prevent: admin-spa/src/components/EmailBuilder/EmailBuilder.tsx - Block edit dialog
  • Prevent: Template editor dialogs with rich content
  • Allow: Simple confirmation dialogs
  • Allow: View-only information dialogs

Best Practice:

When in doubt, prevent dismissal for editing dialogs. It's better to require explicit Cancel/Save than risk data loss.

Responsive Dialog/Drawer Pattern:

For settings pages and forms, use ResponsiveDialog component that automatically switches between Dialog (desktop) and Drawer (mobile):

import { ResponsiveDialog } from '@/components/ui/responsive-dialog';

<ResponsiveDialog
  open={isOpen}
  onOpenChange={setIsOpen}
  title={__('Edit Settings')}
  description={__('Configure your settings')}
  footer={
    <div className="flex gap-2">
      <Button variant="outline" onClick={() => setIsOpen(false)}>
        {__('Cancel')}
      </Button>
      <Button onClick={handleSave}>
        {__('Save')}
      </Button>
    </div>
  }
>
  {/* Form content */}
</ResponsiveDialog>

Behavior:

  • Desktop (≥768px): Shows centered Dialog
  • Mobile (<768px): Shows bottom Drawer for better reachability

Component: admin-spa/src/components/ui/responsive-dialog.tsx

5.9 Settings Page Layout Pattern

WooNooW enforces a consistent layout pattern for all settings pages to ensure predictable UX and maintainability.

Core Principle: All settings pages MUST use SettingsLayout component with contextual header.

Implementation Pattern:

import { SettingsLayout } from './components/SettingsLayout';

export default function MySettingsPage() {
  const [settings, setSettings] = useState({...});
  const [isLoading, setIsLoading] = useState(true);
  
  const handleSave = async () => {
    // Save logic
  };
  
  if (isLoading) {
    return (
      <SettingsLayout
        title={__('Page Title')}
        description={__('Page description')}
        isLoading={true}
      >
        <div className="animate-pulse h-64 bg-muted rounded-lg"></div>
      </SettingsLayout>
    );
  }
  
  return (
    <SettingsLayout
      title={__('Page Title')}
      description={__('Page description')}
      onSave={handleSave}
      saveLabel={__('Save Changes')}
    >
      {/* Settings content - automatically boxed with max-w-5xl */}
      <SettingsCard title={__('Section Title')}>
        {/* Form fields */}
      </SettingsCard>
    </SettingsLayout>
  );
}

SettingsLayout Props:

Prop Type Required Description
title string | ReactNode Yes Page title shown in contextual header
description string No Subtitle/description below title
onSave () => Promise<void> No Save handler - shows Save button in header
saveLabel string No Custom label for save button (default: "Save changes")
isLoading boolean No Shows loading state
action ReactNode No Custom action buttons (e.g., Back button)

Layout Behavior:

  1. Contextual Header (Mobile + Desktop)

    • Shows page title and description
    • Shows Save button if onSave provided
    • Shows custom actions if action provided
    • Sticky at top of page
  2. Content Area

    • Automatically boxed with max-w-5xl mx-auto
    • Responsive padding and spacing
    • Consistent with other admin pages
  3. No Inline Header

    • When using onSave or action, inline header is hidden
    • Title/description only appear in contextual header
    • Saves vertical space

Rules for Settings Pages:

  1. Always use SettingsLayout - Never create custom layout
  2. Pass title/description to layout - Don't render inline headers
  3. Use onSave for save actions - Don't render save buttons in content
  4. Use SettingsCard for sections - Consistent card styling
  5. Show loading state - Use isLoading prop during data fetch
  6. Never use full-width layout - Content is always boxed
  7. Never duplicate save buttons - One save button in header only

Examples:

  • Good: admin-spa/src/routes/Settings/Customers.tsx
  • Good: admin-spa/src/routes/Settings/Notifications/Staff.tsx
  • Good: admin-spa/src/routes/Settings/Notifications/Customer.tsx

Files:

  • Layout component: admin-spa/src/routes/Settings/components/SettingsLayout.tsx
  • Card component: admin-spa/src/routes/Settings/components/SettingsCard.tsx

5.9 Mobile Contextual Header Pattern

WooNooW implements a dual-header system for mobile-first UX, ensuring actionable pages have consistent navigation and action buttons.

Concept: Two Headers on Mobile

  1. Contextual Header (Mobile + Desktop)

    • Common actions that work everywhere
    • Format: [Back Button] Page Title [Primary Action]
    • Always visible (sticky)
    • Examples: Back, Edit, Save, Create
  2. Page Header / Extra Actions (Desktop Only)

    • Additional desktop-specific actions
    • Hidden on mobile (hidden md:flex)
    • Examples: Print, Invoice, Label, Export

Implementation Pattern

import { usePageHeader } from '@/contexts/PageHeaderContext';
import { Button } from '@/components/ui/button';

export default function MyPage() {
  const { setPageHeader, clearPageHeader } = usePageHeader();
  const nav = useNavigate();
  
  // Set contextual header
  useEffect(() => {
    const actions = (
      <div className="flex gap-2">
        <Button size="sm" variant="ghost" onClick={() => nav('/parent')}>
          {__('Back')}
        </Button>
        <Button size="sm" onClick={handlePrimaryAction}>
          {__('Save')}
        </Button>
      </div>
    );
    
    setPageHeader(__('Page Title'), actions);
    return () => clearPageHeader();
  }, [dependencies]);
  
  return (
    <div>
      {/* Desktop-only extra actions */}
      <div className="hidden md:flex gap-2">
        <button onClick={printAction}>{__('Print')}</button>
        <button onClick={exportAction}>{__('Export')}</button>
      </div>
      
      {/* Page content */}
    </div>
  );
}

Rules for CRUD Pages

Page Type Contextual Header Page Header
List None (list page) Filters, Search
Detail [Back] Title [Edit] Print, Invoice, Label
New [Back] Title [Create] None
Edit [Back] Title [Save] None

Form Submit Pattern

For New/Edit pages, move submit button to contextual header:

// Use formRef to trigger submit from header
const formRef = useRef<HTMLFormElement>(null);

const actions = (
  <Button onClick={() => formRef.current?.requestSubmit()}>
    {__('Save')}
  </Button>
);

<OrderForm formRef={formRef} hideSubmitButton={true} />

Best Practices

  1. No Duplication - If action is in contextual header, remove from page body
  2. Mobile First - Contextual header shows essential actions only
  3. Desktop Enhancement - Extra actions in page header (desktop only)
  4. Consistent Pattern - All CRUD pages follow same structure
  5. Loading States - Buttons show loading state during mutations

Files

  • admin-spa/src/contexts/PageHeaderContext.tsx - Context provider
  • admin-spa/src/hooks/usePageHeader.ts - Hook for setting headers
  • admin-spa/src/components/PageHeader.tsx - Header component

5.8 Error Handling & User Notifications

WooNooW implements a centralized, user-friendly error handling system that ensures consistent UX across all features.

Core Principles

  1. Never expose technical details to end users (no "API 500", stack traces, or raw error codes)
  2. Use appropriate notification types based on context
  3. Provide actionable feedback with clear next steps
  4. Maintain consistency across all pages and features

Notification Types

Context Component Use Case Example
Page Load Errors <ErrorCard> Query failures, data fetch errors "Failed to load orders" with retry button
Action Errors toast.error() Mutation failures, form submissions "Failed to create order. Please check all required fields."
Action Success toast.success() Successful mutations "Order created successfully"
Inline Validation <ErrorMessage> Form field errors "Email address is required"

Implementation

// For mutations (create, update, delete)
import { showErrorToast, showSuccessToast } from '@/lib/errorHandling';

const mutation = useMutation({
  mutationFn: OrdersApi.create,
  onSuccess: (data) => {
    showSuccessToast('Order created successfully', `Order #${data.number} created`);
  },
  onError: (error) => {
    showErrorToast(error); // Automatically extracts user-friendly message
  }
});

// For queries (page loads)
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';

if (query.isError) {
  return <ErrorCard 
    title="Failed to load data"
    message={getPageLoadErrorMessage(query.error)}
    onRetry={() => query.refetch()}
  />;
}

Error Message Mapping

Backend errors are mapped to user-friendly messages in lib/errorHandling.ts:

const friendlyMessages = {
  'no_items': 'Please add at least one product to the order',
  'create_failed': 'Failed to create order. Please check all required fields.',
  'update_failed': 'Failed to update order. Please check all fields.',
  'not_found': 'The requested item was not found',
  'forbidden': 'You do not have permission to perform this action',
};

Toast Configuration

  • Position: Bottom-right
  • Duration: 4s (success), 6s (errors)
  • Theme: Light mode with colored backgrounds
  • Colors: Green (success), Red (error), Amber (warning), Blue (info)

Files

  • admin-spa/src/lib/errorHandling.ts — Centralized error utilities
  • admin-spa/src/components/ErrorCard.tsx — Page load error component
  • admin-spa/src/components/ui/sonner.tsx — Toast configuration

5.9 Data Validation & Required Fields

WooNooW enforces strict validation rules to ensure data integrity and provide clear feedback to users.

Order Creation Validation

All orders must include:

Field Requirement Error Message
Products At least 1 product "At least one product is required"
Billing First Name Required "Billing first name is required"
Billing Last Name Required "Billing last name is required"
Billing Email Required & valid format "Billing email is required" / "Billing email is not valid"
Billing Address Required "Billing address is required"
Billing City Required "Billing city is required"
Billing Postcode Required "Billing postcode is required"
Billing Country Required "Billing country is required"

Backend Validation Response

When validation fails, the API returns:

{
  "error": "validation_failed",
  "message": "Please complete all required fields",
  "fields": [
    "Billing first name is required",
    "Billing email is required",
    "Billing address is required"
  ]
}

Frontend Display

The error handling utility automatically formats field errors as a bulleted list:

❌ Please complete all required fields

• Billing first name is required
• Billing email is required
• Billing address is required
• Billing city is required
• Billing postcode is required

Each field error appears as a bullet point on its own line, making it easy for users to scan and see exactly what needs to be fixed.

Implementation Location

  • Backend validation: includes/Api/OrdersController.php create() method
  • Frontend handling: admin-spa/src/lib/errorHandling.ts getErrorMessage()

5.10 Internationalization (i18n)

WooNooW follows WordPress translation standards to ensure all user-facing strings are translatable.

Text Domain: woonoow

Backend (PHP)

Use WordPress translation functions:

// Simple translation
__( 'Billing first name', 'woonoow' )

// Translation with sprintf
sprintf( __( '%s is required', 'woonoow' ), $field_label )

// Translators comment for context
/* translators: %s: field label */
sprintf( __( '%s is required', 'woonoow' ), $label )

Frontend (TypeScript/React)

Use the i18n utility wrapper:

import { __, sprintf } from '@/lib/i18n';

// Simple translation
__('Failed to load data')

// Translation with sprintf (placeholders)
sprintf(__('Order #%s created'), orderNumber)
sprintf(__('Edit Order #%s'), orderId)

// In components
<button>{__('Try again')}</button>
<h2>{sprintf(__('Order #%s'), order.number)}</h2>

// In error messages
const title = __('Please complete all required fields');
const message = sprintf(__('Order #%s has been created'), data.number);

Translation Files

  • Backend strings: Extracted to languages/woonoow.pot
  • Frontend strings: Loaded via wp.i18n (WordPress handles this)
  • Translation utilities: admin-spa/src/lib/i18n.ts

Best Practices

  1. Never hardcode user-facing strings - Always use translation functions
  2. Use translators comments for context when using placeholders
  3. Keep strings simple - Avoid complex concatenation
  4. Test in English first - Ensure strings make sense before translation

5.11 Loading States

WooNooW provides a consistent loading UI system across the application to ensure a polished user experience.

Component: admin-spa/src/components/LoadingState.tsx

Loading Components

**1. LoadingState (Default)**a

import { LoadingState } from '@/components/LoadingState';

// Default loading
<LoadingState />

// Custom message
<LoadingState message={__('Loading order...')} />

// Different sizes
<LoadingState size="sm" message={__('Saving...')} />
<LoadingState size="md" message={__('Loading...')} /> // default
<LoadingState size="lg" message={__('Processing...')} />

// Full screen overlay
<LoadingState fullScreen message={__('Loading...')} />

2. PageLoadingState

import { PageLoadingState } from '@/components/LoadingState';

// For full page loads
if (isLoading) {
  return <PageLoadingState message={__('Loading order...')} />;
}

3. InlineLoadingState

import { InlineLoadingState } from '@/components/LoadingState';

// For inline loading within components
{isLoading && <InlineLoadingState message={__('Loading...')} />}

4. CardLoadingSkeleton

import { CardLoadingSkeleton } from '@/components/LoadingState';

// For loading card content
{isLoading && <CardLoadingSkeleton />}

5. TableLoadingSkeleton

import { TableLoadingSkeleton } from '@/components/LoadingState';

// For loading table rows
{isLoading && <TableLoadingSkeleton rows={10} />}

Usage Guidelines

Page-Level Loading:

// ✅ Good - Use PageLoadingState for full page loads
if (orderQ.isLoading || countriesQ.isLoading) {
  return <PageLoadingState message={sprintf(__('Loading order #%s...'), orderId)} />;
}

// ❌ Bad - Don't use plain text
if (isLoading) {
  return <div>Loading...</div>;
}

Inline Loading:

// ✅ Good - Use InlineLoadingState for partial loads
{q.isLoading && <InlineLoadingState message={__('Loading order...')} />}

// ❌ Bad - Don't use custom spinners
{q.isLoading && <div><Loader2 className="animate-spin" /> Loading...</div>}

Table Loading:

// ✅ Good - Use TableLoadingSkeleton for tables
{q.isLoading && <TableLoadingSkeleton rows={10} />}

// ❌ Bad - Don't show empty state while loading
{q.isLoading && <div>Loading data...</div>}

Best Practices

  1. Always use i18n - All loading messages must be translatable

    <LoadingState message={__('Loading...')} />
    
  2. Be specific - Use descriptive messages

    // ✅ Good
    <LoadingState message={sprintf(__('Loading order #%s...'), orderId)} />
    
    // ❌ Bad
    <LoadingState message="Loading..." />
    
  3. Choose appropriate size - Match the context

    • sm - Inline, buttons, small components
    • md - Default, cards, sections
    • lg - Full page, important actions
  4. Use skeletons for lists - Better UX than spinners

    {isLoading ? <TableLoadingSkeleton rows={5} /> : <Table data={data} />}
    
  5. Responsive design - Loading states work on all screen sizes

    • Mobile: Optimized spacing and sizing
    • Desktop: Full layout preserved

Pattern Examples

Order Edit Page:

export default function OrdersEdit() {
  const orderQ = useQuery({ ... });
  
  if (orderQ.isLoading) {
    return <LoadingState message={sprintf(__('Loading order #%s...'), orderId)} />;
  }
  
  return <OrderForm ... />;
}

Order Detail Page:

export default function OrderDetail() {
  const q = useQuery({ ... });
  
  return (
    <div>
      <h1>{__('Order Details')}</h1>
      {q.isLoading && <InlineLoadingState message={__('Loading order...')} />}
      {q.data && <OrderContent order={q.data} />}
    </div>
  );
}

Orders List:

export default function OrdersList() {
  const q = useQuery({ ... });
  
  return (
    <table>
      <thead>...</thead>
      <tbody>
        {q.isLoading && <TableLoadingSkeleton rows={10} />}
        {q.data?.map(order => <OrderRow key={order.id} order={order} />)}
      </tbody>
    </table>
  );
}

6. 🔌 Addon Development Standards

6.1 Addon Injection System

WooNooW provides a filter-based addon injection system that allows third-party plugins to integrate seamlessly with the SPA without modifying core files.

Core Principle: All modules that can accept external injection MUST provide filter hooks following the standard naming convention.

6.2 Hook Naming Convention

All WooNooW hooks follow this structure:

woonoow/{category}/{action}[/{subcategory}]

Examples:

  • woonoow/addon_registry - Register addon metadata
  • woonoow/spa_routes - Register SPA routes
  • woonoow/nav_tree - Modify navigation tree
  • woonoow/nav_tree/products/children - Inject into Products submenu
  • woonoow/dashboard/widgets - Add dashboard widgets (future)
  • woonoow/order/detail/panels - Add order detail panels (future)

Rules:

  1. Always prefix with woonoow/
  2. Use lowercase with underscores
  3. Use singular nouns for registries (addon_registry, not addons_registry)
  4. Use hierarchical structure for nested items
  5. Use descriptive names that indicate purpose

6.3 Filter Template Pattern

When creating a new module that accepts external injection, follow this template:

Backend (PHP)

<?php
namespace WooNooW\Compat;

class MyModuleRegistry {
    const OPTION_KEY = 'wnw_my_module_data';
    const VERSION = '1.0.0';
    
    public static function init() {
        add_action('plugins_loaded', [__CLASS__, 'collect_data'], 30);
        add_action('activated_plugin', [__CLASS__, 'flush']);
        add_action('deactivated_plugin', [__CLASS__, 'flush']);
    }
    
    public static function collect_data() {
        $data = [];
        
        /**
         * Filter: woonoow/my_module/items
         * 
         * Allows addons to register items with this module.
         * 
         * @param array $data Array of item configurations
         * 
         * Example:
         * add_filter('woonoow/my_module/items', function($data) {
         *     $data['my-item'] = [
         *         'id'    => 'my-item',
         *         'label' => 'My Item',
         *         'value' => 'something',
         *     ];
         *     return $data;
         * });
         */
        $data = apply_filters('woonoow/my_module/items', $data);
        
        // Validate and store
        $validated = self::validate_items($data);
        update_option(self::OPTION_KEY, [
            'version' => self::VERSION,
            'items'   => $validated,
            'updated' => time(),
        ], false);
    }
    
    private static function validate_items(array $items): array {
        // Validation logic
        return $items;
    }
    
    public static function get_items(): array {
        $data = get_option(self::OPTION_KEY, []);
        return $data['items'] ?? [];
    }
    
    public static function flush() {
        delete_option(self::OPTION_KEY);
    }
    
    public static function get_frontend_data(): array {
        // Return sanitized data for frontend
        return self::get_items();
    }
}

Expose to Frontend (Assets.php)

// In localize_runtime() method
wp_localize_script($handle, 'WNW_MY_MODULE', MyModuleRegistry::get_frontend_data());
wp_add_inline_script($handle, 'window.WNW_MY_MODULE = window.WNW_MY_MODULE || WNW_MY_MODULE;', 'after');

Frontend (TypeScript)

// Read from window
const moduleData = (window as any).WNW_MY_MODULE || [];

// Use in component
function MyComponent() {
  const items = (window as any).WNW_MY_MODULE || [];
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>{item.label}</div>
      ))}
    </div>
  );
}

6.4 Documentation Requirements

When adding a new filter hook, you MUST:

  1. Add to Hook Registry (see section 6.5)
  2. Document in code with PHPDoc
  3. Add example in ADDON_INJECTION_GUIDE.md
  4. Update ADDONS_ADMIN_UI_REQUIREMENTS.md

6.5 Hook Registry

See HOOKS_REGISTRY.md for complete list of available hooks and filters.

6.6 Non-React Addon Development

Question: Can developers build addons without React?

Answer: YES! WooNooW supports multiple addon approaches:

Approach 1: PHP + HTML/CSS/JS (No React)

Traditional WordPress addon development works perfectly:

<?php
/**
 * Plugin Name: My Traditional Addon
 */

// Register addon
add_filter('woonoow/addon_registry', function($addons) {
    $addons['my-addon'] = [
        'id'      => 'my-addon',
        'name'    => 'My Addon',
        'version' => '1.0.0',
    ];
    return $addons;
});

// Add navigation item that links to classic admin page
add_filter('woonoow/nav_tree', function($tree) {
    $tree[] = [
        'key'      => 'my-addon',
        'label'    => 'My Addon',
        'path'     => '/my-addon-classic', // Will redirect to admin page
        'icon'     => 'puzzle',
        'children' => [],
    ];
    return $tree;
});

// Register classic admin page
add_action('admin_menu', function() {
    add_menu_page(
        'My Addon',
        'My Addon',
        'manage_options',
        'my-addon-page',
        'my_addon_render_page',
        'dashicons-admin-generic',
        30
    );
});

function my_addon_render_page() {
    ?>
    <div class="wrap">
        <h1>My Traditional Addon</h1>
        <p>Built with PHP, HTML, CSS, and vanilla JS!</p>
        
        <script>
        // Vanilla JavaScript works fine
        document.addEventListener('DOMContentLoaded', function() {
            console.log('My addon loaded!');
        });
        </script>
    </div>
    <?php
}

This approach:

  • Works with WooNooW navigation
  • No React knowledge required
  • Uses standard WordPress admin pages
  • Can use WordPress admin styles
  • Can enqueue own CSS/JS
  • ⚠️ Opens in separate page (not SPA)

Approach 2: Vanilla JS Component (No React)

For developers who want SPA integration without React:

// dist/MyAddon.js - Vanilla JS module
export default function MyAddonPage(props) {
    const container = document.createElement('div');
    container.className = 'p-6';
    container.innerHTML = `
        <div class="rounded-lg border border-border p-6 bg-card">
            <h2 class="text-xl font-semibold mb-2">My Addon</h2>
            <p class="text-sm opacity-70">Built with vanilla JavaScript!</p>
            <button id="my-button" class="px-4 py-2 bg-blue-500 text-white rounded">
                Click Me
            </button>
        </div>
    `;
    
    // Add event listeners
    setTimeout(() => {
        const button = container.querySelector('#my-button');
        button.addEventListener('click', () => {
            alert('Vanilla JS works!');
        });
    }, 0);
    
    return container;
}

This approach:

  • Integrates with SPA
  • No React required
  • Can use Tailwind classes
  • Can fetch from REST API
  • ⚠️ Must return DOM element
  • ⚠️ Manual state management

Approach 3: React Component (Full SPA)

For developers comfortable with React:

// dist/MyAddon.tsx - React component
import React from 'react';

export default function MyAddonPage() {
    const [count, setCount] = React.useState(0);
    
    return (
        <div className="p-6">
            <div className="rounded-lg border border-border p-6 bg-card">
                <h2 className="text-xl font-semibold mb-2">My Addon</h2>
                <p className="text-sm opacity-70">Built with React!</p>
                <button 
                    onClick={() => setCount(count + 1)}
                    className="px-4 py-2 bg-blue-500 text-white rounded"
                >
                    Clicked {count} times
                </button>
            </div>
        </div>
    );
}

This approach:

  • Full SPA integration
  • React state management
  • Can use React Query
  • Can use WooNooW components
  • Best UX
  • ⚠️ Requires React knowledge

6.7 Addon Development Checklist

When creating a module that accepts addons:

  • Create Registry class (e.g., MyModuleRegistry.php)
  • Add filter hook with woonoow/ prefix
  • Document filter in PHPDoc with example
  • Expose data to frontend via Assets.php
  • Add to HOOKS_REGISTRY.md
  • Add example to ADDON_INJECTION_GUIDE.md
  • Test with example addon
  • Update ADDONS_ADMIN_UI_REQUIREMENTS.md

6.8 Orders Module as Reference

The Orders module is the reference implementation:

  • No external injection (by design)
  • Clean route structure
  • Type-safe components
  • Proper error handling
  • Mobile responsive
  • i18n complete

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
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:

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

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

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

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:

1. Normal Mode (wp-admin)

  • Access: /wp-admin/admin.php?page=woonoow
  • Layout: Traditional WordPress admin with WooNooW SPA in content area
  • Use Case: Standard WordPress admin workflow
  • Features:
    • WordPress admin bar and sidebar visible
    • Full WordPress admin functionality
    • WooNooW SPA integrated seamlessly
    • Settings submenu hidden (use WooCommerce settings)
  • When to use: When you need access to other WordPress admin features alongside WooNooW

2. Fullscreen Mode

  • Access: Toggle button in WooNooW header
  • Layout: WooNooW SPA only (no WordPress chrome)
  • Use Case: Focused work sessions, order processing
  • Features:
    • Maximized workspace
    • Distraction-free interface
    • All WooNooW features accessible
    • Settings submenu hidden
  • When to use: When you want to focus exclusively on WooNooW tasks

3. Standalone Mode

  • Access: https://yoursite.com/admin
  • Layout: Complete standalone application with custom login
  • Use Case: Quick daily access, mobile-friendly, bookmark-able
  • Features:
    • Custom login page (/admin#/login)
    • WordPress authentication integration
    • Settings submenu visible (SPA settings pages)
    • "WordPress" button to access wp-admin
    • "Logout" button in header
    • Admin bar link in wp-admin to standalone
    • Session persistence across modes
  • When to use: As your primary WooNooW interface, especially on mobile or for quick access

Mode Switching

  • From wp-admin to Standalone: Click "WooNooW" in admin bar
  • From Standalone to wp-admin: Click "WordPress" button in header
  • To Fullscreen: Click fullscreen toggle in any mode
  • Session persistence: Login state is shared across all modes

Settings Submenu Behavior

  • Normal Mode: No settings submenu (use WooCommerce settings in wp-admin)
  • Fullscreen Mode: No settings submenu
  • Standalone Mode: Full settings submenu visible with SPA pages

Implementation: Settings submenu uses dynamic getter in admin-spa/src/nav/tree.ts:

get children() {
  const isStandalone = (window as any).WNW_CONFIG?.standaloneMode;
  if (!isStandalone) return [];
  return [ /* settings items */ ];
}

8. 🤖 AI Agent Collaboration Rules

When using an AI IDE agent (ChatGPT, Claude, etc.):

Step 1: Context Injection

Always load:

  • README.md
  • PROJECT_SOP.md
  • The specific file(s) being edited

Step 2: Editing Rules

  1. All AI edits must be idempotent — never break structure or naming conventions.
  2. Always follow PSR12 PHP standard and React code conventions.
  3. When unsure about a design decision, refer back to this S.O.P. before guessing.
  4. New files must be registered in the correct namespace path.
  5. When editing React components, ensure build compatibility with Vite.

Step 3: Communication

AI agents must:

  • Explain each patch clearly.
  • Never autoremove code without reason.
  • Maintain English for all code comments, Markdown for docs.

7. 📦 Release Steps

  1. Run all builds:
    npm run build && npm run pack
    
  2. Test in LocalWP with a sample Woo store.
  3. Validate HPOS compatibility and order creation flow.
  4. Push final woonoow.zip to release channel (Sejoli, member.dwindi.com, or manual upload).
  5. Tag version using semantic versioning (e.g. v0.2.0-beta).

8. 🧭 Decision Hierarchy

Category Decision Reference
Code Style Follow PSR12 (PHP) & Airbnb/React rules
Architecture PSR4 + modular single responsibility
UI/UX Modern minimal style, standardized using Tailwind + Shadcn UI. Recharts for data visualization.
Icons Use lucide-react via npm i lucide-react. Icons should match Shadcn UI guidelines. Always import directly (e.g. import { Package } from 'lucide-react'). Maintain consistent size (1620px) and stroke width (1.5px). Use Tailwind classes for color states.
Navigation Pattern CRUD pages MUST follow consistent back button navigation: New Order: Index ← New. Edit Order: Index ← Detail ← Edit. Back button always goes to parent page, not index. Use ArrowLeft icon from lucide-react. Toolbar format: <button onClick={() => nav('/parent/path')}><ArrowLeft /> Back</button> <h2>Page Title</h2>
Compatibility Must preserve Woo hooks unless explicitly replaced
Performance Async-first, no blocking mail or sync jobs
Email Policy ALL wp_mail() calls MUST be delayed by 15+ seconds using Action Scheduler or wp-cron. Never send emails synchronously during API requests (create, update, status change). Use OrdersController::schedule_order_email() pattern.
Deployment LocalWP → Coolify → Production

9. 🧩 Future Extensions

  • Addon Manager (JSON feed + licensing integration).
  • Admin Insights (charts, sales analytics with React).
  • Storefront SPA Theme Override (optional full React mode).
  • Developer SDK for 3rd-party addon compatibility.

10. 📜 License & Ownership

All rights reserved to Dwindi (dewe.dev).
The WooNooW project may include GPL-compatible code portions for WordPress compliance.
Redistribution without written consent is prohibited outside official licensing channels.