Files
WooNooW/admin-spa/src/routes/Settings/Payments.tsx
dwindown dd2ff2074f fix: Login logo 401, link focus styles, payment/shipping active colors
## 1. Fix Logo 401 Error on Login 

**Issue:** Logo image returns 401 Unauthorized on login page

**Root Cause:** `/store/settings` endpoint requires authentication

**Solution:** Created public branding endpoint
```php
// GET /woonoow/v1/store/branding (PUBLIC)
public function get_branding() {
    return [
        'store_name' => get_option('blogname'),
        'store_logo' => get_option('woonoow_store_logo'),
        'store_icon' => get_option('woonoow_store_icon'),
        'store_tagline' => get_option('woonoow_store_tagline'),
    ];
}
```

**Frontend:** Updated Login.tsx to use `/store/branding` instead

**Result:** Logo loads without authentication 

---

## 2. Override WordPress Link Focus Styles 

**Issue:** WordPress common.css applies focus/active styles to links

**Solution:** Added CSS override
```css
a:focus,
a:active {
  outline: none !important;
  box-shadow: none !important;
}
```

**Result:** Clean focus states, no WordPress interference

---

## 3. Active Color for Manual Payment Methods 

**Issue:** Manual payments use static gray icon, online payments use green/primary

**Solution:** Applied same active color logic
```tsx
<div className={`p-2 rounded-lg ${
  gateway.enabled
    ? 'bg-green-500/20 text-green-500'
    : 'bg-primary/10 text-primary'
}`}>
  <Banknote className="h-5 w-5" />
</div>
```

**Result:**
-  Enabled = Green background + green icon
-  Disabled = Primary background + primary icon
-  Consistent with online payments

---

## 4. Active Color for Shipping Icons 

**Issue:** Shipping icons always gray, no visual indicator of enabled state

**Solution:** Applied active color to all shipping icons
- Zone summary view
- Desktop accordion view
- Mobile accordion view

```tsx
<div className={`p-2 rounded-lg ${
  rate.enabled
    ? 'bg-green-500/20 text-green-500'
    : 'bg-primary/10 text-primary'
}`}>
  <Truck className="h-4 w-4" />
</div>
```

**Result:**
-  Enabled shipping = Green icon
-  Disabled shipping = Primary icon
-  Consistent visual language across payments & shipping

---

## 5. Notification Strategy 

**Acknowledged:** Clean structure, ready for implementation

---

## Summary

 Public branding endpoint (no auth required)
 Logo loads on login page
 WordPress link focus styles overridden
 Manual payments have active colors
 Shipping methods have active colors
 Consistent visual language (green = active, primary = inactive)

**Visual Consistency Achieved:**
- Payments (manual & online) ✓
- Shipping methods ✓
- All use same color system ✓
2025-11-11 00:03:14 +07:00

531 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: any) {
console.error('Failed to save order:', error);
console.error('Error response:', error.response?.data);
toast.error(error.response?.data?.message || '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: any) {
console.error('Failed to save order:', error);
console.error('Error response:', error.response?.data);
toast.error(error.response?.data?.message || '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 hover:border-primary/50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-lg ${gateway.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
<Banknote className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">{gateway.method_title || gateway.title}</h3>
{gateway.description && (
<p
className="text-sm text-muted-foreground mt-1"
dangerouslySetInnerHTML={{ __html: gateway.description }}
/>
)}
</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"
dangerouslySetInnerHTML={{ __html: gateway.method_description }}
/>
)}
{!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>
)}
</>
);
}