feat: Implement drag-and-drop sorting for payment methods

Implemented sortable payment gateways using @dnd-kit

Features:
 Drag-and-drop for Manual Payment Methods
 Drag-and-drop for Online Payment Methods
 Visual drag handle (GripVertical icon)
 Smooth animations during drag
 Separate sorting for each category
 Order persists in component state
 Toast notification on reorder

UI Changes:
- Added drag handle on left side of each gateway card
- Cursor changes to grab/grabbing during drag
- Dragged item becomes semi-transparent (50% opacity)
- Smooth transitions between positions

Implementation:
1. DnD Context Setup
   - PointerSensor for mouse/touch
   - KeyboardSensor for accessibility
   - closestCenter collision detection

2. Sortable Items
   - SortableGatewayItem wrapper component
   - Handles drag attributes and listeners
   - Applies transform and transition styles

3. State Management
   - manualOrder: Array of manual gateway IDs
   - onlineOrder: Array of online gateway IDs
   - Initialized from gateways on mount
   - Updated on drag end

4. Sorting Logic
   - useMemo to sort gateways by custom order
   - arrayMove from @dnd-kit/sortable
   - Separate handlers for each category

5. Visual Feedback
   - GripVertical icon (left side, 8px from edge)
   - Opacity 0.5 when dragging
   - Smooth CSS transitions
   - Cursor: grab/grabbing

TODO (Backend):
- Save order to WordPress options
- Load order on page load
- API endpoint: POST /payments/gateways/order

Benefits:
 Better UX for organizing payment methods
 Visual feedback during drag
 Accessible (keyboard support)
 Separate sorting per category
 No page reload needed

Files Modified:
- Payments.tsx: DnD implementation
- package.json: @dnd-kit dependencies (already installed)
This commit is contained in:
dwindown
2025-11-06 13:50:33 +07:00
parent 2aaa43dd26
commit b57a23ffbd

View File

@@ -109,6 +109,16 @@ export default function PaymentsPage() {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Initialize order from gateways
React.useEffect(() => {
if (gateways.length > 0 && manualOrder.length === 0 && onlineOrder.length === 0) {
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);
setOnlineOrder(online);
}
}, [gateways, manualOrder.length, onlineOrder.length]);
// Toggle gateway mutation
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => {
@@ -151,15 +161,65 @@ export default function PaymentsPage() {
setIsModalOpen(true);
};
// Handle drag end for manual gateways
const handleManualDragEnd = (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);
// TODO: Save order to backend
toast.success('Payment methods reordered');
};
// Handle drag end for online gateways
const handleOnlineDragEnd = (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);
// TODO: Save order to backend
toast.success('Payment methods reordered');
};
const handleSaveGateway = async (settings: Record<string, unknown>) => {
if (!selectedGateway) return;
await saveMutation.mutateAsync({ id: selectedGateway.id, settings });
};
// Categorize gateways
const manualGateways = gateways.filter((g: PaymentGateway) => g.type === 'manual');
// Combine provider and other into single "3rd party" category
const thirdPartyGateways = gateways.filter((g: PaymentGateway) => g.type === 'provider' || g.type === 'other');
// 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 (
@@ -197,48 +257,51 @@ export default function PaymentsPage() {
{manualGateways.length === 0 ? (
<p className="text-sm text-muted-foreground">No manual payment methods available</p>
) : (
<div className="space-y-4">
{manualGateways.map((gateway: PaymentGateway) => (
<div
key={gateway.id}
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" />
<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>
<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>
</SortableGatewayItem>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</SettingsCard>
@@ -248,60 +311,63 @@ export default function PaymentsPage() {
title="Online Payment Methods"
description="Accept credit cards, digital wallets, and other online payments"
>
<div className="space-y-4">
{thirdPartyGateways.map((gateway: PaymentGateway) => (
<div
key={gateway.id}
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>
<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>
{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>
</SortableGatewayItem>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
</SettingsCard>
)}
</SettingsLayout>