## 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 ✓
531 lines
20 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|