Files
WooNooW/admin-spa/src/routes/Settings/Payments.tsx
dwindown 52f7c1b99d 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
2025-11-06 13:59:37 +07:00

526 lines
20 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { SettingsLayout } from './components/SettingsLayout';
import { SettingsCard } from './components/SettingsCard';
import { ToggleField } from './components/ToggleField';
import { GenericGatewayForm } from '@/components/settings/GenericGatewayForm';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CreditCard, Banknote, Settings, RefreshCw, ExternalLink, Loader2, AlertTriangle, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { useMediaQuery } from '@/hooks/use-media-query';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface GatewayField {
id: string;
type: string;
title: string;
description: string;
default: string | boolean;
value: string | boolean; // Current saved value
placeholder?: string;
required: boolean;
options?: Record<string, string>;
custom_attributes?: Record<string, string>;
}
interface PaymentGateway {
id: string;
title: string;
description: string;
enabled: boolean;
type: 'manual' | 'provider' | 'other';
icon: string;
method_title: string;
method_description: string;
supports: string[];
requirements: {
met: boolean;
missing: string[];
};
settings: {
basic: Record<string, GatewayField>;
api: Record<string, GatewayField>;
advanced: Record<string, GatewayField>;
};
has_fields: boolean;
webhook_url: string | null;
has_custom_ui: boolean;
wc_settings_url: string;
}
// Sortable Gateway Item Component
function SortableGatewayItem({ gateway, children }: { gateway: PaymentGateway; children: React.ReactNode }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: gateway.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className="relative">
{/* 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" />
</div>
<div className="md:pl-8">
{children}
</div>
</div>
);
}
export default function PaymentsPage() {
const queryClient = useQueryClient();
const [selectedGateway, setSelectedGateway] = useState<PaymentGateway | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [togglingGateway, setTogglingGateway] = useState<string | null>(null);
const [manualOrder, setManualOrder] = useState<string[]>([]);
const [onlineOrder, setOnlineOrder] = useState<string[]>([]);
const isDesktop = useMediaQuery("(min-width: 768px)");
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Fetch all payment gateways
const { data: gatewayData, isLoading, refetch } = useQuery({
queryKey: ['payment-gateways'],
queryFn: () => api.get('/payments/gateways'),
refetchOnWindowFocus: true,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// 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(() => {
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);
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);
}
}
}, [gateways, savedOrder, manualOrder.length, onlineOrder.length]);
// Toggle gateway mutation
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => {
setTogglingGateway(id);
return api.post(`/payments/gateways/${id}/toggle`, { enabled });
},
onSuccess: async () => {
// Wait for refetch to complete before showing toast
await queryClient.invalidateQueries({ queryKey: ['payment-gateways'] });
toast.success('Gateway updated successfully');
setTogglingGateway(null);
},
onError: () => {
toast.error('Failed to update gateway');
setTogglingGateway(null);
},
});
// Save gateway settings mutation
const saveMutation = useMutation({
mutationFn: ({ id, settings }: { id: string; settings: Record<string, unknown> }) =>
api.post(`/payments/gateways/${id}`, settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payment-gateways'] });
toast.success('Settings saved successfully');
setIsModalOpen(false);
setSelectedGateway(null);
},
onError: () => {
toast.error('Failed to save settings');
},
});
const handleToggle = (id: string, enabled: boolean) => {
toggleMutation.mutate({ id, enabled });
};
const handleManageGateway = (gateway: PaymentGateway) => {
setSelectedGateway(gateway);
setIsModalOpen(true);
};
// Handle drag end for manual gateways
const handleManualDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = manualOrder.indexOf(active.id as string);
const newIndex = manualOrder.indexOf(over.id as string);
const newOrder = arrayMove(manualOrder, oldIndex, newIndex);
setManualOrder(newOrder);
// Save order to backend
try {
await api.post('/payments/gateways/order', {
category: 'manual',
order: newOrder,
});
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
const handleOnlineDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = onlineOrder.indexOf(active.id as string);
const newIndex = onlineOrder.indexOf(over.id as string);
const newOrder = arrayMove(onlineOrder, oldIndex, newIndex);
setOnlineOrder(newOrder);
// Save order to backend
try {
await api.post('/payments/gateways/order', {
category: 'online',
order: newOrder,
});
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>) => {
if (!selectedGateway) return;
await saveMutation.mutateAsync({ id: selectedGateway.id, settings });
};
// Separate and sort gateways by type and custom order
const manualGateways = React.useMemo(() => {
const manual = gateways.filter((g: PaymentGateway) => g.type === 'manual');
if (manualOrder.length === 0) return manual;
return [...manual].sort((a, b) => {
const indexA = manualOrder.indexOf(a.id);
const indexB = manualOrder.indexOf(b.id);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}, [gateways, manualOrder]);
const thirdPartyGateways = React.useMemo(() => {
const online = gateways.filter((g: PaymentGateway) => g.type === 'provider' || g.type === 'other');
if (onlineOrder.length === 0) return online;
return [...online].sort((a, b) => {
const indexA = onlineOrder.indexOf(a.id);
const indexB = onlineOrder.indexOf(b.id);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}, [gateways, onlineOrder]);
if (isLoading) {
return (
<SettingsLayout title="Payments" description="Manage how you get paid">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</SettingsLayout>
);
}
return (
<>
<SettingsLayout
title="Payments"
description="Manage how you get paid"
action={
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
}
>
{/* Manual Payment Methods - First priority */}
<SettingsCard
title="Manual Payment Methods"
description="Accept payments outside your online store"
>
{manualGateways.length === 0 ? (
<p className="text-sm text-muted-foreground">No manual payment methods available</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleManualDragEnd}>
<SortableContext items={manualGateways.map((g: PaymentGateway) => g.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-4">
{manualGateways.map((gateway: PaymentGateway) => (
<SortableGatewayItem key={gateway.id} gateway={gateway}>
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
<Banknote className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">{gateway.method_title || gateway.title}</h3>
{gateway.description && (
<p className="text-sm text-muted-foreground mt-1">
{gateway.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{gateway.enabled && (
<Button
variant="ghost"
size="sm"
onClick={() => handleManageGateway(gateway)}
>
<Settings className="h-4 w-4" />
</Button>
)}
<ToggleField
id={gateway.id}
label=""
checked={gateway.enabled}
onCheckedChange={(checked) => handleToggle(gateway.id, checked)}
disabled={togglingGateway === gateway.id}
/>
</div>
</div>
</div>
</SortableGatewayItem>
))}
</div>
</SortableContext>
</DndContext>
)}
</SettingsCard>
{/* Online Payment Methods - Flat list */}
{thirdPartyGateways.length > 0 && (
<SettingsCard
title="Online Payment Methods"
description="Accept credit cards, digital wallets, and other online payments"
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnlineDragEnd}>
<SortableContext items={thirdPartyGateways.map((g: PaymentGateway) => g.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-4">
{thirdPartyGateways.map((gateway: PaymentGateway) => (
<SortableGatewayItem key={gateway.id} gateway={gateway}>
<div className="border rounded-lg p-4 hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<div className={`p-2 rounded-lg ${gateway.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
<CreditCard className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold">
{gateway.method_title || gateway.title}
</h3>
</div>
{gateway.method_description && (
<p className="text-sm text-muted-foreground mb-2">
{gateway.method_description}
</p>
)}
{!gateway.requirements.met && (
<Alert variant="destructive" className="mt-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Requirements not met: {gateway.requirements.missing.join(', ')}
</AlertDescription>
</Alert>
)}
</div>
</div>
<div className="flex items-center gap-0">
{gateway.enabled && (
<Button
variant="ghost"
size="sm"
onClick={() => handleManageGateway(gateway)}
>
<Settings className="h-4 w-4" />
</Button>
)}
<ToggleField
id={gateway.id}
label=""
checked={gateway.enabled}
onCheckedChange={(checked) => handleToggle(gateway.id, checked)}
disabled={!gateway.requirements.met || togglingGateway === gateway.id}
/>
</div>
</div>
</div>
</SortableGatewayItem>
))}
</div>
</SortableContext>
</DndContext>
</SettingsCard>
)}
</SettingsLayout>
{/* Gateway Settings Modal - Responsive: Dialog on desktop, Drawer on mobile */}
{selectedGateway && isDesktop && (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<DialogTitle>{selectedGateway.title} Settings</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 min-h-0">
<GenericGatewayForm
gateway={selectedGateway}
onSave={handleSaveGateway}
onCancel={() => setIsModalOpen(false)}
hideFooter
/>
</div>
{/* Footer outside scrollable area */}
<div className="border-t px-6 py-4 flex items-center justify-between shrink-0 bg-background rounded-b-lg">
<Button
type="button"
variant="outline"
onClick={() => setIsModalOpen(false)}
disabled={saveMutation.isPending}
>
Cancel
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
asChild
>
<a
href={selectedGateway.wc_settings_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
>
View in WooCommerce
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
onClick={() => {
const form = document.querySelector('form');
if (form) form.requestSubmit();
}}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{selectedGateway && !isDesktop && (
<Drawer open={isModalOpen} onOpenChange={setIsModalOpen}>
<DrawerContent className="max-h-[90vh] flex flex-col">
<DrawerHeader className="border-b shrink-0">
<DrawerTitle>{selectedGateway.title} Settings</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-6 min-h-0">
<GenericGatewayForm
gateway={selectedGateway}
onSave={handleSaveGateway}
onCancel={() => setIsModalOpen(false)}
hideFooter
/>
</div>
{/* Footer outside scrollable area */}
<div className="border-t px-4 py-3 flex flex-col gap-2 shrink-0 bg-background">
<Button
type="button"
variant="ghost"
asChild
className="w-full"
>
<a
href={selectedGateway.wc_settings_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1"
>
View in WooCommerce
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
onClick={() => {
const form = document.querySelector('form');
if (form) form.requestSubmit();
}}
disabled={saveMutation.isPending}
className="w-full"
>
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsModalOpen(false)}
disabled={saveMutation.isPending}
className="w-full"
>
Cancel
</Button>
</div>
</DrawerContent>
</Drawer>
)}
</>
);
}