feat: Hide drag handle on mobile + persist sort order to database

1. Hide Drag Handle on Mobile 

   Problem: Drag handle looks messy on mobile
   Solution: Hide on mobile, show only on desktop

   Changes:
   - Added 'hidden md:block' to drag handle
   - Added 'md:pl-8' to content wrapper
   - Mobile: Clean list without drag handle
   - Desktop: Drag handle visible for sorting

   UX Priority: Better mobile experience > sorting on mobile

2. Persist Sort Order to Database 

   Backend Implementation:

   A. New API Endpoint
      POST /woonoow/v1/payments/gateways/order
      Body: { category: 'manual'|'online', order: ['id1', 'id2'] }

   B. Save to WordPress Options
      - woonoow_payment_gateway_order_manual
      - woonoow_payment_gateway_order_online

   C. Load Order on Page Load
      GET /payments/gateways returns:
      {
        gateways: [...],
        order: {
          manual: ['bacs', 'cheque', 'cod'],
          online: ['paypal', 'stripe']
        }
      }

   Frontend Implementation:

   A. Save on Drag End
      - Calls API immediately after reorder
      - Shows success toast
      - Reverts on error with error toast

   B. Load Saved Order
      - Extracts order from API response
      - Uses saved order if available
      - Falls back to gateway order if no saved order

   C. Error Handling
      - Try/catch on save
      - Revert order on failure
      - User feedback via toast

3. Flow Diagram

   Page Load:
   ┌─────────────────────────────────────┐
   │ GET /payments/gateways              │
   ├─────────────────────────────────────┤
   │ Returns: { gateways, order }        │
   │ - order.manual: ['bacs', 'cod']     │
   │ - order.online: ['paypal']          │
   └─────────────────────────────────────┘
              ↓
   ┌─────────────────────────────────────┐
   │ Initialize State                    │
   │ - setManualOrder(order.manual)      │
   │ - setOnlineOrder(order.online)      │
   └─────────────────────────────────────┘
              ↓
   ┌─────────────────────────────────────┐
   │ Display Sorted List                 │
   │ - useMemo sorts by saved order      │
   └─────────────────────────────────────┘

   User Drags:
   ┌─────────────────────────────────────┐
   │ User drags item                     │
   └─────────────────────────────────────┘
              ↓
   ┌─────────────────────────────────────┐
   │ handleDragEnd                       │
   │ - Calculate new order               │
   │ - Update state (optimistic)         │
   └─────────────────────────────────────┘
              ↓
   ┌─────────────────────────────────────┐
   │ POST /payments/gateways/order       │
   │ Body: { category, order }           │
   └─────────────────────────────────────┘
              ↓
   ┌─────────────────────────────────────┐
   │ Success: Toast notification         │
   │ Error: Revert + error toast         │
   └─────────────────────────────────────┘

4. Mobile vs Desktop

   Mobile (< 768px):
    Clean list without drag handle
    No left padding
    Better UX
    No sorting (desktop only)

   Desktop (≥ 768px):
    Drag handle visible
    Full sorting capability
    Visual feedback
    Keyboard accessible

Benefits:
 Order persists across sessions
 Order persists across page reloads
 Clean mobile UI
 Full desktop functionality
 Error handling with rollback
 Optimistic UI updates

Files Modified:
- PaymentsController.php: New endpoint + load order
- Payments.tsx: Save order + load order + mobile hide
- Database: 2 new options for order storage
This commit is contained in:
dwindown
2025-11-06 13:59:37 +07:00
parent b57a23ffbd
commit 52f7c1b99d
2 changed files with 130 additions and 16 deletions

View File

@@ -74,10 +74,11 @@ function SortableGatewayItem({ gateway, children }: { gateway: PaymentGateway; c
return ( return (
<div ref={setNodeRef} style={style} className="relative"> <div ref={setNodeRef} style={style} className="relative">
<div className="absolute left-2 top-1/2 -translate-y-1/2 cursor-grab active:cursor-grabbing" {...attributes} {...listeners}> {/* Drag handle - hidden on mobile for better UX */}
<div className="hidden md:block absolute left-2 top-1/2 -translate-y-1/2 cursor-grab active:cursor-grabbing" {...attributes} {...listeners}>
<GripVertical className="h-5 w-5 text-muted-foreground" /> <GripVertical className="h-5 w-5 text-muted-foreground" />
</div> </div>
<div className="pl-8"> <div className="md:pl-8">
{children} {children}
</div> </div>
</div> </div>
@@ -102,22 +103,36 @@ export default function PaymentsPage() {
); );
// Fetch all payment gateways // Fetch all payment gateways
const { data: gateways = [], isLoading, refetch } = useQuery({ const { data: gatewayData, isLoading, refetch } = useQuery({
queryKey: ['payment-gateways'], queryKey: ['payment-gateways'],
queryFn: () => api.get('/payments/gateways'), queryFn: () => api.get('/payments/gateways'),
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
}); });
// Initialize order from gateways // Extract gateways and order from response
const gateways = gatewayData?.gateways || [];
const savedOrder = gatewayData?.order || { manual: [], online: [] };
// Initialize order from saved order or gateways
React.useEffect(() => { React.useEffect(() => {
if (gateways.length > 0 && manualOrder.length === 0 && onlineOrder.length === 0) { if (gateways.length > 0 && manualOrder.length === 0 && onlineOrder.length === 0) {
// Use saved order if available, otherwise use gateway order
if (savedOrder.manual.length > 0) {
setManualOrder(savedOrder.manual);
} else {
const manual = gateways.filter((g: PaymentGateway) => g.type === 'manual').map((g: PaymentGateway) => g.id); const manual = gateways.filter((g: PaymentGateway) => g.type === 'manual').map((g: PaymentGateway) => g.id);
const online = gateways.filter((g: PaymentGateway) => g.type === 'provider' || g.type === 'other').map((g: PaymentGateway) => g.id);
setManualOrder(manual); setManualOrder(manual);
}
if (savedOrder.online.length > 0) {
setOnlineOrder(savedOrder.online);
} else {
const online = gateways.filter((g: PaymentGateway) => g.type === 'provider' || g.type === 'other').map((g: PaymentGateway) => g.id);
setOnlineOrder(online); setOnlineOrder(online);
} }
}, [gateways, manualOrder.length, onlineOrder.length]); }
}, [gateways, savedOrder, manualOrder.length, onlineOrder.length]);
// Toggle gateway mutation // Toggle gateway mutation
const toggleMutation = useMutation({ const toggleMutation = useMutation({
@@ -162,7 +177,7 @@ export default function PaymentsPage() {
}; };
// Handle drag end for manual gateways // Handle drag end for manual gateways
const handleManualDragEnd = (event: DragEndEvent) => { const handleManualDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
@@ -172,12 +187,23 @@ export default function PaymentsPage() {
const newOrder = arrayMove(manualOrder, oldIndex, newIndex); const newOrder = arrayMove(manualOrder, oldIndex, newIndex);
setManualOrder(newOrder); setManualOrder(newOrder);
// TODO: Save order to backend // Save order to backend
try {
await api.post('/payments/gateways/order', {
category: 'manual',
order: newOrder,
});
toast.success('Payment methods reordered'); toast.success('Payment methods reordered');
} catch (error) {
console.error('Failed to save order:', error);
toast.error('Failed to save order');
// Revert order on error
setManualOrder(manualOrder);
}
}; };
// Handle drag end for online gateways // Handle drag end for online gateways
const handleOnlineDragEnd = (event: DragEndEvent) => { const handleOnlineDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
@@ -187,8 +213,19 @@ export default function PaymentsPage() {
const newOrder = arrayMove(onlineOrder, oldIndex, newIndex); const newOrder = arrayMove(onlineOrder, oldIndex, newIndex);
setOnlineOrder(newOrder); setOnlineOrder(newOrder);
// TODO: Save order to backend // Save order to backend
try {
await api.post('/payments/gateways/order', {
category: 'online',
order: newOrder,
});
toast.success('Payment methods reordered'); toast.success('Payment methods reordered');
} catch (error) {
console.error('Failed to save order:', error);
toast.error('Failed to save order');
// Revert order on error
setOnlineOrder(onlineOrder);
}
}; };
const handleSaveGateway = async (settings: Record<string, unknown>) => { const handleSaveGateway = async (settings: Record<string, unknown>) => {

View File

@@ -94,6 +94,31 @@ class PaymentsController extends WP_REST_Controller {
], ],
], ],
]); ]);
// POST /woonoow/v1/payments/gateways/order
register_rest_route($this->namespace, '/' . $this->rest_base . '/gateways/order', [
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [$this, 'save_gateway_order'],
'permission_callback' => [$this, 'check_permission'],
'args' => [
'category' => [
'description' => 'Gateway category (manual or online)',
'type' => 'string',
'required' => true,
'enum' => ['manual', 'online'],
],
'order' => [
'description' => 'Array of gateway IDs in desired order',
'type' => 'array',
'required' => true,
'items' => [
'type' => 'string',
],
],
],
],
]);
} }
/** /**
@@ -106,7 +131,17 @@ class PaymentsController extends WP_REST_Controller {
try { try {
$gateways = PaymentGatewaysProvider::get_gateways(); $gateways = PaymentGatewaysProvider::get_gateways();
$response = rest_ensure_response($gateways); // Get saved order
$manual_order = get_option('woonoow_payment_gateway_order_manual', []);
$online_order = get_option('woonoow_payment_gateway_order_online', []);
$response = rest_ensure_response([
'gateways' => $gateways,
'order' => [
'manual' => $manual_order,
'online' => $online_order,
],
]);
// Cache for 5 minutes // Cache for 5 minutes
$response->header('Cache-Control', 'max-age=300'); $response->header('Cache-Control', 'max-age=300');
@@ -265,6 +300,48 @@ class PaymentsController extends WP_REST_Controller {
} }
} }
/**
* Save gateway order
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_gateway_order(WP_REST_Request $request) {
$category = $request->get_param('category');
$order = $request->get_param('order');
// Validate category
if (!in_array($category, ['manual', 'online'], true)) {
return new WP_Error(
'invalid_category',
'Category must be either "manual" or "online"',
['status' => 400]
);
}
// Validate order is array
if (!is_array($order)) {
return new WP_Error(
'invalid_order',
'Order must be an array of gateway IDs',
['status' => 400]
);
}
// Save to WordPress options
$option_key = 'woonoow_payment_gateway_order_' . $category;
update_option($option_key, $order, false);
error_log(sprintf('[WooNooW] Saved %s gateway order: %s', $category, implode(', ', $order)));
return rest_ensure_response([
'success' => true,
'message' => 'Gateway order saved successfully',
'category' => $category,
'order' => $order,
]);
}
/** /**
* Check permission * Check permission
* *