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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user