Removed static method-level fallback. Shipping method selector now:
1. Shows 'Enter shipping address to see available rates' when address incomplete
2. Calls calculate_shipping endpoint to get actual WC_Shipping_Rate objects
3. Displays rate-level options (e.g., JNE REG, JNE YES) not method-level
This ensures third-party shipping plugins like Rajaongkir, UPS, FedEx
display their courier rates correctly.
1. Shipping method selector now shows static shippings list when
address is not complete, instead of 'No shipping methods available'.
Only shows the empty message when address IS complete but no methods
matched.
2. Variation selector in Dialog and Drawer now displays attribute names
(Size, Dispenser) in semibold and values (30ml, pump) in normal
weight for better visual hierarchy.
Instead of mounting to body (which breaks scoped styles), we now
mount the popover portal to #woonoow-admin-app. This ensures
dropdowns inherit the correct CSS variables and styling.
Moved 'Register as site member' from order-level to site-level setting
Frontend Changes:
1. Customer Settings - Added new General section
- Auto-register customers as site members toggle
- Clear description of functionality
- Saved to backend via existing API
2. OrderForm - Removed checkbox
- Removed registerAsMember state
- Removed checkbox UI
- Removed register_as_member from payload
- Backend now uses site setting
Backend Changes:
1. CustomerSettingsProvider.php
- Added auto_register_members setting
- Default: false (no)
- Stored as woonoow_auto_register_members option
- Included in get_settings()
- Handled in update_settings()
2. OrdersController.php
- Removed register_as_member parameter
- Now reads from CustomerSettingsProvider
- Site-level setting applies to all orders
- Consistent behavior across all order creation
Benefits:
✅ Site-level control (not per-order)
✅ Consistent customer experience
✅ Easier to manage (one setting)
✅ No UI clutter in order form
✅ Setting persists across all orders
Migration:
- Old orders with checkbox: No impact
- New orders: Use site setting
- Default: Disabled (safe default)
Result:
Admins can now control customer registration site-wide from Customer Settings instead of per-order checkbox
Issue 1: Shipping recalculation on order edit (FIXED)
- Problem: OrderForm recalculated shipping on every edit
- Expected: Shipping should be fixed unless address changes
- Solution: Use existing order.totals.shipping in edit mode
- Create mode: Still calculates from shipping method
Issue 2: Meta fields not appearing without data (DOCUMENTED)
- Problem: Private meta fields dont appear if no data exists yet
- Example: Admin cannot input tracking number on first time
- Root cause: Fields only exposed if data exists in database
- Solution: Plugins MUST register fields via MetaFieldsRegistry
- Registration makes field available even when empty
Updated METABOX_COMPAT.md:
- Changed optional to REQUIRED for field registration
- Added critical warning section
- Explained private vs public meta behavior
- Private meta: MUST register to appear
- Public meta: Auto-exposed, no registration needed
The Flow (Corrected):
1. Plugin registers field -> Field appears in UI (even empty)
2. Admin inputs data -> Saved to database
3. Data visible in both admins
Without Registration:
- Private meta (_field): Not exposed, not editable
- Public meta (field): Auto-exposed, auto-editable
Why Private Meta Requires Registration:
- Security: Hidden by default
- Privacy: Prevents exposing sensitive data
- Control: Plugins explicitly declare visibility
Files Changed:
- OrderForm.tsx: Use existing shipping total in edit mode
- METABOX_COMPAT.md: Critical documentation updates
Result:
- Shipping no longer recalculates on edit
- Clear documentation on field registration requirement
- Developers know they MUST register private meta fields
Following PROJECT_SOP.md section 5.7 - Variable Product Handling:
**Backend (OrdersController.php):**
- Updated /products/search endpoint to return:
- Product type (simple/variable)
- Variations array with attributes, prices, stock
- Formatted attribute names (Color, Size, etc.)
**Frontend (OrderForm.tsx):**
- Updated ProductSearchItem type to include variations
- Updated LineItem type to support variation_id and variation_name
- Added variation selector drawer (mobile + desktop)
- Each variation = separate cart item row
- Display variation name below product name
- Fixed remove button to work with variations (by index)
**UX Pattern:**
1. Search product → If variable, show variation drawer
2. Select variation → Add as separate line item
3. Can add same product with different variations
4. Each variation shown clearly: 'Product Name' + 'Color: Red'
**Result:**
✅ Tokopedia/Shopee pattern implemented
✅ No auto-selection of first variation
✅ Each variation is independent cart item
✅ Works on mobile and desktop
**Next:** Fix PageHeader max-w-5xl to only apply on settings pages
## Three Issues Fixed ✅
### 1. Backend hit on every keypress ❌
**Problem:**
- Type "Bandung" → 7 API calls (B, Ba, Ban, Band, Bandu, Bandun, Bandung)
- Expensive for live rate APIs (Rajaongkir, UPS)
- Poor UX with constant loading
**Solution - Debouncing:**
```ts
const [debouncedCity, setDebouncedCity] = useState(city);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedCity(city);
}, 500); // Wait 500ms after user stops typing
return () => clearTimeout(timer);
}, [city]);
// Use debouncedCity in query key
queryKey: [..., debouncedCity]
```
**Result:**
- Type "Bandung" → Wait 500ms → 1 API call ✅
- Much better UX and performance
---
### 2. Same rates for different provinces ❌
**Problem:**
- Select "Jawa Barat" → JNE REG Rp31,000
- Select "Bali" → JNE REG Rp31,000 (wrong!)
- Should be different rates
**Root Cause:**
```ts
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
```
React Query was caching too aggressively. Even though query key changed (different state), it was returning cached data.
**Solution:**
```ts
gcTime: 0, // Don't cache in memory
staleTime: 0, // Always refetch when key changes
```
**Result:**
- Select "Jawa Barat" → Fetch → JNE REG Rp31,000
- Select "Bali" → Fetch → JNE REG Rp45,000 ✅
- Correct rates for each province
---
### 3. No Rajaongkir API hits ❌
**Problem:**
- Check Rajaongkir dashboard → No new API calls
- Rates never actually calculated
- Using stale cached data
**Root Cause:**
Same as #2 - aggressive caching prevented real API calls
**Solution:**
Disabled caching completely for shipping calculations:
```ts
gcTime: 0, // No garbage collection time
staleTime: 0, // No stale time
```
**Result:**
- Change province → Real Rajaongkir API call ✅
- Fresh rates every time ✅
- Dashboard shows API usage ✅
---
## How It Works Now:
### User Types City:
```
1. Type "B" → Timer starts (500ms)
2. Type "a" → Timer resets (500ms)
3. Type "n" → Timer resets (500ms)
4. Type "dung" → Timer resets (500ms)
5. Stop typing → Wait 500ms
6. ✅ API call with "Bandung"
```
### User Changes Province:
```
1. Select "Jawa Barat"
2. Query key changes
3. ✅ Fetch fresh rates (no cache)
4. ✅ Rajaongkir API called
5. Returns: JNE REG Rp31,000
6. Select "Bali"
7. Query key changes
8. ✅ Fetch fresh rates (no cache)
9. ✅ Rajaongkir API called again
10. Returns: JNE REG Rp45,000 (different!)
```
## Benefits:
- ✅ No more keypress spam
- ✅ Correct rates per province
- ✅ Real API calls to Rajaongkir
- ✅ Fresh data always
- ✅ Better UX with 500ms debounce
## Critical Bug Fixed ✅
### Problem:
- User fills billing address (Country, State, City)
- Shipping says "No shipping methods available"
- Backend returns empty methods array
- No rates calculated
### Root Cause:
Frontend was only checking `shippingData` for completeness:
```ts
if (!shippingData.country) return false;
if (!shippingData.city) return false;
```
But when user doesn't check "Ship to different address":
- `shippingData` is empty {}
- Billing address has all the data
- Query never enabled!
### Solution:
Use effective shipping address based on `shipDiff` toggle:
```ts
const effectiveShippingAddress = useMemo(() => {
if (shipDiff) {
return shippingData; // Use separate shipping address
}
// Use billing address
return {
country: bCountry,
state: bState,
city: bCity,
postcode: bPost,
address_1: bAddr1,
};
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]);
```
Then check completeness on effective address:
```ts
const isComplete = useMemo(() => {
const addr = effectiveShippingAddress;
if (!addr.country) return false;
if (!addr.city) return false;
if (hasStates && !addr.state) return false;
return true;
}, [effectiveShippingAddress]);
```
### Backend Enhancement:
Also set billing address for tax calculation context:
```php
// Set both shipping and billing for proper tax calculation
WC()->customer->set_shipping_country( $country );
WC()->customer->set_billing_country( $country );
```
## Result:
### Before:
1. Fill billing: Indonesia, Jawa Barat, Bandung
2. Shipping: "No shipping methods available" ❌
3. No API call made
### After:
1. Fill billing: Indonesia, Jawa Barat, Bandung
2. ✅ API called with billing address
3. ✅ Returns: JNE REG, JNE YES, TIKI REG
4. ✅ First rate auto-selected
5. ✅ Total calculated with tax
## Testing:
- ✅ Fill billing only → Shipping calculated
- ✅ Check "Ship to different" → Use shipping address
- ✅ Uncheck → Switch back to billing
- ✅ Change billing city → Rates recalculate
## Issues Fixed:
### 1. Shipping rates fetched on page load ✅
**Problem:**
- Open New Order form → Shipping already calculated
- Using cached/legacy values
- Should wait for address to be filled
**Solution:**
Added address completeness check:
```ts
const isShippingAddressComplete = useMemo(() => {
if (!shippingData.country) return false;
if (!shippingData.city) return false;
// If country has states, require state
const countryStates = states[shippingData.country];
if (countryStates && Object.keys(countryStates).length > 0) {
if (!shippingData.state) return false;
}
return true;
}, [shippingData.country, shippingData.state, shippingData.city]);
```
Query only enabled when address is complete:
```ts
enabled: isShippingAddressComplete && items.length > 0
```
### 2. Unnecessary refetches ✅
**Problem:**
- Every keystroke triggered refetch
- staleTime: 0 meant always refetch
**Solution:**
```ts
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
```
Query key still includes all address fields, so:
- Change country → Refetch (key changed)
- Change state → Refetch (key changed)
- Change city → Refetch (key changed)
- Change postcode → Refetch (key changed)
- Same values → Use cache (key unchanged)
### 3. Order preview fetching too early ✅
**Problem:**
- Preview calculated before shipping method selected
- Incomplete data
**Solution:**
```ts
enabled: items.length > 0 && !!bCountry && !!shippingMethod
```
## New Behavior:
### On Page Load:
- ❌ No shipping fetch
- ❌ No preview fetch
- ✅ Clean state
### User Fills Address:
1. Enter country → Not enough
2. Enter state → Not enough
3. Enter city → ✅ **Fetch shipping rates**
4. Rates appear → First auto-selected
5. ✅ **Fetch order preview** (has method now)
### User Changes Address:
1. Change Jakarta → Bandung
2. Query key changes (city changed)
3. ✅ **Refetch shipping rates**
4. New rates appear → First auto-selected
5. ✅ **Refetch order preview**
### User Types in Same Field:
1. Type "Jak..." → "Jakarta"
2. Query key same (city still "Jakarta")
3. ❌ No refetch (use cache)
4. Efficient!
## Benefits:
- ✅ No premature fetching
- ✅ No unnecessary API calls
- ✅ Smart caching (5 min)
- ✅ Only refetch when address actually changes
- ✅ Better UX and performance
## Issues Fixed:
### 1. Shipping rates not recalculating when address changes ✅
**Problem:**
- Change province → Rates stay the same
- Query was cached incorrectly
**Root Cause:**
Query key only tracked country, state, postcode:
```ts
queryKey: [..., shippingData.country, shippingData.state, shippingData.postcode]
```
But Rajaongkir and other plugins also need:
- City (different rates per city)
- Address (for some plugins)
**Solution:**
```ts
queryKey: [
...,
shippingData.country,
shippingData.state,
shippingData.city, // Added
shippingData.postcode,
shippingData.address_1 // Added
],
staleTime: 0, // Always refetch when key changes
```
### 2. First rate auto-selected but dropdown shows placeholder ✅
**Problem:**
- Rates calculated → First rate used in total
- But dropdown shows "Select shipping"
- Confusing UX
**Solution:**
Added useEffect to auto-select first rate:
```ts
useEffect(() => {
if (shippingRates?.methods?.length > 0) {
const firstRateId = shippingRates.methods[0].id;
const currentExists = shippingRates.methods.some(m => m.id === shippingMethod);
// Auto-select if no selection or current not in new rates
if (!shippingMethod || !currentExists) {
setShippingMethod(firstRateId);
}
}
}, [shippingRates?.methods]);
```
## Benefits:
- ✅ Change province → Rates recalculate immediately
- ✅ First rate auto-selected in dropdown
- ✅ Selection cleared if no rates available
- ✅ Selection preserved if still valid after recalculation
## Testing:
1. Select Jakarta → Shows JNE rates
2. Change to Bali → Rates recalculate, first auto-selected
3. Change to remote area → Different rates, first auto-selected
4. Dropdown always shows current selection
## Error 1: Tax Settings - Empty SelectItem value ✅
**Issue:** Radix UI Select does not allow empty string as SelectItem value
**Error:** "A <Select.Item /> must have a value prop that is not an empty string"
**Solution:**
- Use 'standard' instead of empty string for UI
- Convert 'standard' → '' when submitting to API
- Initialize selectedTaxClass to 'standard'
- Update all dialog handlers to use 'standard'
## Error 2: OrderForm - Undefined shipping variables ✅
**Issue:** Removed individual shipping state variables (sFirst, sLast, sCountry, etc.) but forgot to update all references
**Error:** "Cannot find name 'sCountry'"
**Solution:**
Fixed all remaining references:
1. **useEffect for country sync:** `setSCountry(bCountry)` → `setShippingData({...shippingData, country: bCountry})`
2. **useEffect for state validation:** `sState && !states[sCountry]` → `shippingData.state && !states[shippingData.country]`
3. **Customer autofill:** Individual setters → `setShippingData({ first_name, last_name, ... })`
4. **Removed sStateOptions:** No longer needed with dynamic fields
## Testing:
- ✅ Tax settings page loads without errors
- ✅ Add/Edit tax rate dialog works
- ✅ OrderForm loads without errors
- ✅ Shipping fields render dynamically
- ✅ Customer autofill works with new state structure
Implemented proper contextual header pattern for all Order CRUD pages.
Problem:
- New/Edit pages had action buttons at bottom of form
- Detail page showed duplicate headers (contextual + inline)
- Not following mobile-first best practices
Solution: [Back] Page Title [Action]
1. Edit Order Page
Header: [Back] Edit Order #337 [Save]
Implementation:
- Added formRef to trigger form submit from header
- Save button in contextual header
- Removed submit button from form bottom
- Button shows loading state during save
Changes:
- Edit.tsx: Added formRef, updated header with Save button
- OrderForm.tsx: Added formRef and hideSubmitButton props
- Form submit triggered via formRef.current.requestSubmit()
2. New Order Page
Header: [Back] New Order [Create]
Implementation:
- Added formRef to trigger form submit from header
- Create button in contextual header
- Removed submit button from form bottom
- Button shows loading state during creation
Changes:
- New.tsx: Added formRef, updated header with Create button
- Same OrderForm props as Edit page
3. Order Detail Page
Header: (hidden)
Implementation:
- Cleared contextual header completely
- Detail page has its own inline header with actions
- Inline header: [Back] Order #337 [Print] [Invoice] [Label] [Edit]
Changes:
- Detail.tsx: clearPageHeader() in useEffect
- No duplicate headers
OrderForm Component Updates:
- Added formRef prop (React.RefObject<HTMLFormElement>)
- Added hideSubmitButton prop (boolean)
- Form element accepts ref: <form ref={formRef}>
- Submit button conditionally rendered: {!hideSubmitButton && <Button...>}
- Backward compatible (both props optional)
Benefits:
✅ Consistent header pattern across all CRUD pages
✅ Action buttons always visible (sticky header)
✅ Better mobile UX (no scrolling to find buttons)
✅ Loading states in header buttons
✅ Clean, modern interface
✅ Follows industry standards (Gmail, Notion, Linear)
Files Modified:
- routes/Orders/New.tsx
- routes/Orders/Edit.tsx
- routes/Orders/Detail.tsx
- routes/Orders/partials/OrderForm.tsx
Result:
✅ New/Edit: Action buttons in contextual header
✅ Detail: No contextual header (has inline header)
✅ Professional, mobile-first UX! 🎯