feat: Connect Payments page to real WooCommerce API

 Phase 1 Frontend Complete!

🎨 Payments.tsx - Complete Rewrite:
- Replaced mock data with real API calls
- useQuery to fetch gateways from /payments/gateways
- useMutation for toggle and save operations
- Optimistic updates for instant UI feedback
- Refetch on window focus (5 min stale time)
- Manual refresh button
- Loading states with spinner
- Empty states with helpful messages
- Error handling with toast notifications

🏗️ Gateway Categorization:
- Manual methods (Bank Transfer, COD, Check)
- Payment providers (Stripe, PayPal, etc.)
- Other WC-compliant gateways
- Auto-discovers all installed gateways

🎯 Features:
- Enable/disable toggle with optimistic updates
- Manage button opens settings modal
- GenericGatewayForm for configuration
- Requirements checking (SSL, extensions)
- Link to WC settings for complex cases
- Responsive design
- Keyboard accessible

📋 Checklist Progress:
- [x] PaymentGatewaysProvider.php
- [x] PaymentsController.php
- [x] GenericGatewayForm.tsx
- [x] Update Payments.tsx with real API
- [ ] Test with real WooCommerce (next)

🎉 Backend + Frontend integration complete!
Ready for testing with actual WooCommerce installation.
This commit is contained in:
dwindown
2025-11-05 21:19:53 +07:00
parent 0944e20625
commit 213870a4e2
2 changed files with 319 additions and 200 deletions

View File

@@ -1,237 +1,352 @@
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 { CreditCard, DollarSign, Banknote, Settings } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CreditCard, Banknote, Settings, RefreshCw, ExternalLink, Loader2, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
interface PaymentProvider {
interface GatewayField {
id: string;
name: string;
type: string;
title: string;
description: string;
default: string | boolean;
placeholder?: string;
required: boolean;
options?: Record<string, string>;
custom_attributes?: Record<string, string>;
}
interface PaymentGateway {
id: string;
title: string;
description: string;
icon: React.ReactNode;
enabled: boolean;
connected: boolean;
fees?: string;
testMode?: 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;
}
export default function PaymentsPage() {
const [testMode, setTestMode] = useState(false);
const [providers] = useState<PaymentProvider[]>([
{
id: 'stripe',
name: 'Stripe Payments',
description: 'Accept Visa, Mastercard, Amex, and more',
icon: <CreditCard className="h-6 w-6" />,
enabled: false,
connected: false,
fees: '2.9% + $0.30 per transaction',
},
{
id: 'paypal',
name: 'PayPal',
description: 'Accept PayPal payments worldwide',
icon: <DollarSign className="h-6 w-6" />,
enabled: true,
connected: true,
fees: '3.49% + fixed fee per transaction',
},
]);
const queryClient = useQueryClient();
const [selectedGateway, setSelectedGateway] = useState<PaymentGateway | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [manualMethods, setManualMethods] = useState([
{ id: 'bacs', name: 'Bank Transfer (BACS)', enabled: true },
{ id: 'cod', name: 'Cash on Delivery', enabled: true },
{ id: 'cheque', name: 'Check Payments', enabled: false },
]);
// Fetch all payment gateways
const { data: gateways = [], isLoading, refetch } = useQuery({
queryKey: ['payment-gateways'],
queryFn: () => api.get('/payments/gateways'),
refetchOnWindowFocus: true,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const handleSave = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('Payment settings have been updated successfully.');
// Toggle gateway mutation
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
api.post(`/payments/gateways/${id}/toggle`, { enabled }),
onMutate: async ({ id, enabled }) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: ['payment-gateways'] });
const previous = queryClient.getQueryData(['payment-gateways']);
queryClient.setQueryData(['payment-gateways'], (old: PaymentGateway[]) =>
old.map((g) => (g.id === id ? { ...g, enabled } : g))
);
return { previous };
},
onError: (_err, _variables, context) => {
queryClient.setQueryData(['payment-gateways'], context?.previous);
toast.error('Failed to update gateway');
},
onSuccess: () => {
toast.success('Gateway updated successfully');
},
});
// 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);
};
const toggleManualMethod = (id: string) => {
setManualMethods((prev) =>
prev.map((m) => (m.id === id ? { ...m, enabled: !m.enabled } : m))
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');
const providerGateways = gateways.filter((g: PaymentGateway) => g.type === 'provider');
const otherGateways = gateways.filter((g: PaymentGateway) => g.type === 'other');
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"
onSave={handleSave}
>
{/* Manual Payment Methods - First priority */}
<SettingsCard
title="Manual Payment Methods"
description="Accept payments outside your online store"
>
<div className="space-y-4">
{manualMethods.map((method) => (
<div
key={method.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" />
</div>
<div>
<h3 className="font-medium">{method.name}</h3>
{method.enabled && (
<p className="text-sm text-muted-foreground mt-1">
Customers can choose this at checkout
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{method.enabled && (
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
)}
<ToggleField
id={method.id}
label=""
checked={method.enabled}
onCheckedChange={() => toggleManualMethod(method.id)}
/>
</div>
</div>
</div>
))}
<>
<SettingsLayout title="Payments" description="Manage how you get paid">
{/* Refresh button */}
<div className="flex justify-end mb-4">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</SettingsCard>
{/* Payment Providers - Second priority */}
<SettingsCard
title="Payment Providers"
description="Accept credit cards and digital wallets"
>
<div className="space-y-4">
{providers.map((provider) => (
<div
key={provider.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 bg-primary/10 rounded-lg text-primary">
{provider.icon}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold">{provider.name}</h3>
{provider.connected ? (
<Badge variant="default" className="bg-green-500">
Connected
</Badge>
) : (
<Badge variant="secondary"> Not connected</Badge>
)}
{/* 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>
) : (
<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" />
</div>
<div>
<h3 className="font-medium">{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)}
/>
</div>
<p className="text-sm text-muted-foreground mb-2">
{provider.description}
</p>
{provider.fees && (
<p className="text-xs text-muted-foreground">
{provider.fees}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{provider.connected ? (
<>
<Button variant="outline" size="sm">
))}
</div>
)}
</SettingsCard>
{/* Payment Providers - Second priority */}
<SettingsCard
title="Payment Providers"
description="Accept credit cards and digital wallets"
>
{providerGateways.length === 0 ? (
<p className="text-sm text-muted-foreground">
No payment providers installed.{' '}
<a
href="https://woocommerce.com/product-category/woocommerce-extensions/payment-gateways/"
target="_blank"
rel="noopener noreferrer"
className="underline inline-flex items-center gap-1"
>
Browse payment gateways
<ExternalLink className="h-3 w-3" />
</a>
</p>
) : (
<div className="space-y-4">
{providerGateways.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 bg-primary/10 rounded-lg 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.title}</h3>
{gateway.enabled ? (
<Badge variant="default" className="bg-green-500">
Enabled
</Badge>
) : (
<Badge variant="secondary"> Disabled</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mb-2">
{gateway.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-2">
<Button
variant="outline"
size="sm"
onClick={() => handleManageGateway(gateway)}
>
<Settings className="h-4 w-4 mr-2" />
Manage
</Button>
<Button variant="ghost" size="sm">
Disconnect
</Button>
</>
) : (
<Button size="sm">Set up {provider.name}</Button>
)}
<ToggleField
id={gateway.id}
label=""
checked={gateway.enabled}
onCheckedChange={(checked) => handleToggle(gateway.id, checked)}
disabled={!gateway.requirements.met}
/>
</div>
</div>
</div>
</div>
))}
</div>
))}
)}
</SettingsCard>
<Button variant="outline" className="w-full">
+ Add payment provider
</Button>
</div>
</SettingsCard>
{/* Payment Settings - Third priority (test mode, capture, etc) */}
<SettingsCard
title="Payment Settings"
description="General payment options"
>
{/* Test Mode Banner */}
{testMode && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
<div className="flex items-center gap-2">
<span className="text-yellow-600 dark:text-yellow-400 font-semibold">
Test Mode Active
</span>
<span className="text-sm text-yellow-700 dark:text-yellow-300">
No real charges will be processed
</span>
{/* Other Gateways */}
{otherGateways.length > 0 && (
<SettingsCard
title="Other Payment Methods"
description="Additional payment gateways"
>
<div className="space-y-4">
{otherGateways.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">
<CreditCard className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">{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">
<Button
variant="outline"
size="sm"
asChild
>
<a
href={gateway.wc_settings_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-2" />
Configure in WooCommerce
</a>
</Button>
<ToggleField
id={gateway.id}
label=""
checked={gateway.enabled}
onCheckedChange={(checked) => handleToggle(gateway.id, checked)}
/>
</div>
</div>
</div>
))}
</div>
</div>
</SettingsCard>
)}
</SettingsLayout>
<ToggleField
id="testMode"
label="Test mode"
description="Process test transactions without real charges"
checked={testMode}
onCheckedChange={setTestMode}
/>
<div className="pt-4 border-t">
<div className="space-y-2">
<label className="text-sm font-medium">Payment capture</label>
<p className="text-sm text-muted-foreground">
Choose when to capture payment from customers
</p>
<div className="space-y-2 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="capture" value="automatic" defaultChecked />
<span className="text-sm">
<strong>Authorize and capture</strong> - Charge immediately when order is placed
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="capture" value="manual" />
<span className="text-sm">
<strong>Authorize only</strong> - Manually capture payment later
</span>
</label>
</div>
</div>
</div>
</SettingsCard>
{/* Help Card */}
<div className="bg-muted/50 border rounded-lg p-4">
<p className="text-sm font-medium mb-2">💡 Need help setting up payments?</p>
<p className="text-sm text-muted-foreground">
Our setup wizard can help you connect Stripe or PayPal in minutes.
</p>
<Button variant="link" className="px-0 mt-2">
Start setup wizard
</Button>
</div>
</SettingsLayout>
{/* Gateway Settings Modal */}
{selectedGateway && (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selectedGateway.title} Settings</DialogTitle>
</DialogHeader>
<GenericGatewayForm
gateway={selectedGateway}
onSave={handleSaveGateway}
onCancel={() => setIsModalOpen(false)}
/>
</DialogContent>
</Dialog>
)}
</>
);
}