feat: Phase 2 - Full shipping method management in SPA

Implemented complete CRUD for shipping methods within the SPA!

Frontend Features:
 Tabbed modal (Methods / Details)
 Add shipping method button
 Method selection dialog
 Delete method with confirmation
 Active/Inactive status badges
 Responsive mobile drawer
 Real-time updates via React Query

Backend API:
 GET /methods/available - List all method types
 POST /zones/{id}/methods - Add method to zone
 DELETE /zones/{id}/methods/{instance_id} - Remove method
 GET /zones/{id}/methods/{instance_id}/settings - Get settings
 PUT /zones/{id}/methods/{instance_id}/settings - Update settings

User Flow:
1. Click Edit icon on zone card
2. Modal opens with 2 tabs:
   - Methods: Add/delete methods, see status
   - Details: View zone info
3. Click "Add Method" → Select from available methods
4. Click trash icon → Delete method (with confirmation)
5. All changes sync immediately

What Users Can Do Now:
 Add any shipping method to any zone
 Delete methods from zones
 View method status (Active/Inactive)
 See zone details (name, regions, order)
 Link to WooCommerce for advanced settings

Phase 2 Complete! 🎉
This commit is contained in:
dwindown
2025-11-09 17:24:07 +07:00
parent e053dd73b5
commit 267914dbfe

View File

@@ -7,7 +7,8 @@ import { ToggleField } from './components/ToggleField';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Globe, Truck, MapPin, Edit, Trash2, RefreshCw, Loader2, ExternalLink, Settings } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Globe, Truck, MapPin, Edit, Trash2, RefreshCw, Loader2, ExternalLink, Settings, Plus, X } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { useMediaQuery } from '@/hooks/use-media-query';
@@ -33,6 +34,8 @@ export default function ShippingPage() {
const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
const [selectedZone, setSelectedZone] = useState<any | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('methods');
const [showAddMethod, setShowAddMethod] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
// Fetch shipping zones from WooCommerce
@@ -42,6 +45,13 @@ export default function ShippingPage() {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch available shipping methods
const { data: availableMethods = [] } = useQuery({
queryKey: ['available-shipping-methods'],
queryFn: () => api.get('/settings/shipping/methods/available'),
enabled: showAddMethod,
});
// Toggle shipping method mutation
const toggleMutation = useMutation({
mutationFn: async ({ zoneId, instanceId, enabled }: { zoneId: number; instanceId: number; enabled: boolean }) => {
@@ -59,11 +69,52 @@ export default function ShippingPage() {
},
});
// Add shipping method mutation
const addMethodMutation = useMutation({
mutationFn: async ({ zoneId, methodId }: { zoneId: number; methodId: string }) => {
return api.post(`/settings/shipping/zones/${zoneId}/methods`, { method_id: methodId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Shipping method added successfully'));
setShowAddMethod(false);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to add shipping method'));
},
});
// Delete shipping method mutation
const deleteMethodMutation = useMutation({
mutationFn: async ({ zoneId, instanceId }: { zoneId: number; instanceId: number }) => {
return api.delete(`/settings/shipping/zones/${zoneId}/methods/${instanceId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Shipping method deleted successfully'));
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete shipping method'));
},
});
const handleToggle = (zoneId: number, instanceId: number, enabled: boolean) => {
setTogglingMethod(`${zoneId}-${instanceId}`);
toggleMutation.mutate({ zoneId, instanceId, enabled });
};
const handleAddMethod = (methodId: string) => {
if (selectedZone) {
addMethodMutation.mutate({ zoneId: selectedZone.id, methodId });
}
};
const handleDeleteMethod = (instanceId: number) => {
if (selectedZone && confirm(__('Are you sure you want to delete this shipping method?'))) {
deleteMethodMutation.mutate({ zoneId: selectedZone.id, instanceId });
}
};
if (isLoading) {
return (
<SettingsLayout
@@ -227,44 +278,37 @@ export default function ShippingPage() {
{selectedZone.regions}
</p>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 min-h-0">
<div className="space-y-6">
{/* Zone Summary */}
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{__('Zone Order')}</p>
<p className="text-xs text-muted-foreground">{__('Priority in shipping calculations')}</p>
</div>
<span className="text-2xl font-bold text-muted-foreground">{selectedZone.order}</span>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<TabsList className="mx-6 mt-4">
<TabsTrigger value="methods">{__('Shipping Methods')}</TabsTrigger>
<TabsTrigger value="details">{__('Zone Details')}</TabsTrigger>
</TabsList>
{/* Shipping Methods */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold">{__('Shipping Methods')}</h4>
<span className="text-sm text-muted-foreground">
{selectedZone.rates?.length} {selectedZone.rates?.length === 1 ? 'method' : 'methods'}
</span>
</div>
<TabsContent value="methods" className="flex-1 overflow-y-auto px-6 pb-6 mt-0">
<div className="space-y-4 mt-4">
{/* Add Method Button */}
<Button
variant="outline"
className="w-full"
onClick={() => setShowAddMethod(true)}
>
<Plus className="h-4 w-4 mr-2" />
{__('Add Shipping Method')}
</Button>
{/* Methods List */}
<div className="space-y-3">
{selectedZone.rates?.map((rate: any) => (
<div key={rate.id} className="border rounded-lg p-4 hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div key={rate.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Truck className="h-4 w-4 text-muted-foreground" />
<span className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<span>{__('Cost')}:</span>
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
</span>
{rate.description && (
<span className="text-xs" dangerouslySetInnerHTML={{ __html: rate.description }} />
)}
<div className="text-sm text-muted-foreground">
<span>{__('Cost')}:</span>{' '}
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
</div>
</div>
<div className="flex items-center gap-2">
@@ -275,14 +319,47 @@ export default function ShippingPage() {
}`}>
{rate.enabled ? __('Active') : __('Inactive')}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteMethod(rate.instance_id)}
disabled={deleteMethodMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="details" className="flex-1 overflow-y-auto px-6 pb-6 mt-0">
<div className="space-y-4 mt-4">
<div className="bg-muted/50 rounded-lg p-4">
<div className="space-y-3">
<div>
<p className="text-sm font-medium mb-1">{__('Zone Name')}</p>
<p className="text-sm text-muted-foreground">{selectedZone.name}</p>
</div>
<div>
<p className="text-sm font-medium mb-1">{__('Regions')}</p>
<p className="text-sm text-muted-foreground">{selectedZone.regions}</p>
</div>
<div>
<p className="text-sm font-medium mb-1">{__('Zone Order')}</p>
<p className="text-sm text-muted-foreground">{selectedZone.order}</p>
<p className="text-xs text-muted-foreground mt-1">{__('Priority in shipping calculations')}</p>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">
{__('To edit zone name, regions, or order, use WooCommerce.')}
</p>
</div>
</TabsContent>
</Tabs>
<div className="px-6 py-4 border-t flex justify-between gap-3">
<Button
variant="outline"
@@ -376,6 +453,37 @@ export default function ShippingPage() {
</Drawer>
)
)}
{/* Add Method Dialog */}
<Dialog open={showAddMethod} onOpenChange={setShowAddMethod}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{__('Add Shipping Method')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
{__('Select a shipping method to add to this zone:')}
</p>
<div className="space-y-2">
{availableMethods.map((method: any) => (
<button
key={method.id}
onClick={() => handleAddMethod(method.id)}
disabled={addMethodMutation.isPending}
className="w-full text-left p-4 border rounded-lg hover:border-primary hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="font-medium">{method.title}</div>
{method.description && (
<div className="text-sm text-muted-foreground mt-1">
{method.description}
</div>
)}
</button>
))}
</div>
</div>
</DialogContent>
</Dialog>
</SettingsLayout>
);
}