Files
WooNooW/admin-spa/src/routes/Settings/Shipping.tsx
dwindown 9c5bdebf6f fix: Complete UI/UX polish - all 7 issues resolved
##  Issue 1: Customers Submenu Missing in WP-Admin
**Problem:** Tax and Customer submenus only visible in standalone mode
**Root Cause:** PHP navigation registry did not include Customers
**Fixed:** Added Customers to NavigationRegistry.php settings children
**Result:** Customers submenu now shows in all modes

##  Issue 2: App Logo/Title in Topbar
**Problem:** Should show logo → store name → "WooNooW" fallback
**Fixed:** Header component now:
- Fetches branding from /store/branding endpoint
- Shows logo image if available
- Falls back to store name text
- Updates on store settings change event
**Result:** Proper branding hierarchy in app header

##  Issue 3: Zone Card Header Density on Mobile
**Problem:** "Indonesia Addons" row with 3 icons too cramped on mobile
**Fixed:** Shipping.tsx zone card header:
- Reduced gap from gap-3 to gap-2/gap-1 on mobile
- Smaller font size on mobile (text-sm md:text-lg)
- Added min-w-0 for proper text truncation
- flex-shrink-0 on icon buttons
**Result:** Better mobile spacing and readability

##  Issue 4: Go to WP Admin Button
**Problem:** Should show in standalone mode, not wp-admin
**Fixed:** More page now shows "Go to WP Admin" button:
- Only in standalone mode
- Before Logout button
- Links to /wp-admin
**Result:** Easy access to WP Admin from standalone mode

##  Issue 5: Customer Settings 403 Error
**Problem:** Permission check failing for customer-settings endpoint
**Fixed:** StoreController.php check_permission():
- Added fallback: manage_woocommerce OR manage_options
- Ensures administrators always have access
**Result:** Customer Settings page loads successfully

##  Issue 6: Dark Mode Logo Upload Field
**Problem:** No UI to upload dark mode logo
**Fixed:** Store settings page now has:
- "Store logo (Light mode)" field
- "Store logo (Dark mode)" field (optional)
- Backend support in StoreSettingsProvider
- Full save/load functionality
**Result:** Users can upload separate logos for light/dark modes

##  Issue 7: Login Card Background Too Dark
**Problem:** Login card same color as background in dark mode
**Fixed:** Login.tsx card styling:
- Changed from dark:bg-gray-800 (solid)
- To dark:bg-gray-900/50 (semi-transparent)
- Added backdrop-blur-xl for glass effect
- Added border for definition
**Result:** Login card visually distinct with modern glass effect

---

## Summary

**All 7 Issues Resolved:**
1.  Customers submenu in all modes
2.  Logo/title hierarchy in topbar
3.  Mobile zone card spacing
4.  Go to WP Admin in standalone
5.  Customer Settings permission fix
6.  Dark mode logo upload field
7.  Lighter login card background

**Files Modified:**
- NavigationRegistry.php - Added Customers to nav
- App.tsx - Logo/branding in header
- Shipping.tsx - Mobile spacing
- More/index.tsx - WP Admin button
- StoreController.php - Permission fallback
- Store.tsx - Dark logo field
- StoreSettingsProvider.php - Dark logo backend
- Login.tsx - Card background

**Ready for production!** 🎉
2025-11-11 09:49:31 +07:00

1013 lines
46 KiB
TypeScript

import React, { useState, useEffect } 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 { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Globe, Truck, MapPin, Edit, Trash2, RefreshCw, Loader2, ExternalLink, Settings, Plus, X, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { useMediaQuery } from '@/hooks/use-media-query';
interface ShippingRate {
id: string;
name: string;
price: string;
condition?: string;
transitTime?: string;
}
interface ShippingZone {
id: string;
name: string;
regions: string;
rates: ShippingRate[];
}
export default function ShippingPage() {
const queryClient = useQueryClient();
const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin';
const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
const [selectedZone, setSelectedZone] = useState<any | null>(null);
const [showAddMethod, setShowAddMethod] = useState(false);
const [showAvailableMethods, setShowAvailableMethods] = useState(false);
const [expandedMethod, setExpandedMethod] = useState<string>('');
const [methodSettings, setMethodSettings] = useState<Record<string, any>>({});
const [deletingMethod, setDeletingMethod] = useState<{ zoneId: number; instanceId: number; name: string } | null>(null);
const [showAddZone, setShowAddZone] = useState(false);
const [editingZone, setEditingZone] = useState<any | null>(null);
const [deletingZone, setDeletingZone] = useState<any | null>(null);
const [regionSearch, setRegionSearch] = useState('');
const isDesktop = useMediaQuery("(min-width: 768px)");
// Fetch shipping zones from WooCommerce
const { data: zones = [], isLoading, refetch } = useQuery({
queryKey: ['shipping-zones'],
queryFn: () => api.get('/settings/shipping/zones'),
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: showAvailableMethods,
});
// Fetch available locations (countries/states) for zone regions
const { data: availableLocations = [] } = useQuery({
queryKey: ['available-locations'],
queryFn: () => api.get('/settings/shipping/locations'),
enabled: showAddZone || !!editingZone,
});
// Sync selectedZone with zones data when it changes
useEffect(() => {
if (selectedZone && zones && zones.length > 0) {
const updatedZone = zones.find((z: any) => z.id === selectedZone.id);
if (updatedZone && JSON.stringify(updatedZone) !== JSON.stringify(selectedZone)) {
setSelectedZone(updatedZone);
}
}
}, [zones, selectedZone]);
// Toggle shipping method mutation
const toggleMutation = useMutation({
mutationFn: async ({ zoneId, instanceId, enabled }: { zoneId: number; instanceId: number; enabled: boolean }) => {
return api.post(`/settings/shipping/zones/${zoneId}/methods/${instanceId}/toggle`, { enabled });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Shipping method updated successfully'));
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to update shipping method'));
},
onSettled: () => {
setTogglingMethod(null);
},
});
// 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.del(`/settings/shipping/zones/${zoneId}/methods/${instanceId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Delivery option deleted'));
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete delivery option'));
},
});
// Fetch method settings when accordion expands
const fetchMethodSettings = async (instanceId: number) => {
if (!selectedZone || methodSettings[instanceId]) return;
try {
const settings = await api.get(`/settings/shipping/zones/${selectedZone.id}/methods/${instanceId}/settings`);
setMethodSettings(prev => ({ ...prev, [instanceId]: settings }));
} catch (error) {
console.error('Failed to fetch method settings:', error);
}
};
// Update method settings mutation
const updateSettingsMutation = useMutation({
mutationFn: async ({ zoneId, instanceId, settings }: { zoneId: number; instanceId: number; settings: any }) => {
return api.post(`/settings/shipping/zones/${zoneId}/methods/${instanceId}/settings`, { settings });
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
// Clear cached settings to force refetch
setMethodSettings(prev => {
const newSettings = { ...prev };
delete newSettings[variables.instanceId];
return newSettings;
});
toast.success(__('Settings saved'));
setExpandedMethod('');
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to save settings'));
},
});
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, methodName: string) => {
if (selectedZone) {
setDeletingMethod({ zoneId: selectedZone.id, instanceId, name: methodName });
}
};
const confirmDelete = () => {
if (deletingMethod) {
deleteMethodMutation.mutate({
zoneId: deletingMethod.zoneId,
instanceId: deletingMethod.instanceId
});
setDeletingMethod(null);
}
};
// Zone mutations
const createZoneMutation = useMutation({
mutationFn: async (data: { name: string; regions: any[] }) => {
return api.post('/settings/shipping/zones', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Zone created successfully'));
setShowAddZone(false);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to create zone'));
},
});
const updateZoneMutation = useMutation({
mutationFn: async ({ zoneId, ...data }: { zoneId: number; name?: string; regions?: any[] }) => {
return api.wpFetch(`/woonoow/v1/settings/shipping/zones/${zoneId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Zone updated successfully'));
setEditingZone(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to update zone'));
},
});
const deleteZoneMutation = useMutation({
mutationFn: async (zoneId: number) => {
return api.del(`/settings/shipping/zones/${zoneId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Zone deleted successfully'));
setDeletingZone(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete zone'));
},
});
if (isLoading) {
return (
<SettingsLayout
title={__('Shipping & Delivery')}
description={__('Manage how you ship products to customers')}
>
<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={__('Shipping & Delivery')}
description={__('Manage how you ship products to customers')}
action={
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className="h-4 w-4 mr-2" />
{__('Refresh')}
</Button>
}
>
{/* Shipping Zones */}
<SettingsCard
title={__('Shipping Zones')}
description={__('Create zones to group regions with similar shipping rates')}
action={
<Button
variant="outline"
size="sm"
onClick={() => setShowAddZone(true)}
>
<Plus className="h-4 w-4 mr-2" />
{__('Add Zone')}
</Button>
}
>
{zones.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground mb-4">
{__('No shipping zones configured yet. Create your first zone to start offering shipping.')}
</p>
<Button
onClick={() => setShowAddZone(true)}
>
<Plus className="h-4 w-4 mr-2" />
{__('Create First Zone')}
</Button>
</div>
) : (
<div className="space-y-4">
{zones.map((zone: any) => (
<div
key={zone.id}
className="border rounded-lg p-3 md:p-4 hover:border-primary/50 transition-colors"
>
<div className="flex items-start justify-between gap-2 md:gap-3 mb-3 md:mb-4">
<div className="flex items-start gap-2 md:gap-3 flex-1 min-w-0">
<div className="p-1.5 md:p-2 bg-primary/10 rounded-lg text-primary flex-shrink-0">
<Globe className="h-4 w-4 md:h-5 md:w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm md:text-lg">{zone.name}</h3>
<p className="text-xs md:text-sm text-muted-foreground truncate">
<span className="font-medium">{__('Available to:')}</span> {zone.regions}
</p>
<p className="text-xs md:text-sm text-muted-foreground">
{zone.rates.length} {zone.rates.length === 1 ? __('delivery option') : __('delivery options')}
</p>
</div>
</div>
<div className="flex gap-1 md:gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingZone(zone)}
title={__('Edit zone name and regions')}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingZone(zone)}
title={__('Delete zone')}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedZone(zone)}
title={__('Manage delivery options')}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
{/* Shipping Rates */}
<div className="pl-0 md:pl-11 space-y-2">
{zone.rates?.map((rate: any) => (
<div
key={rate.id}
className="flex items-center justify-between gap-2 py-2 px-2 md:px-3 bg-muted/50 rounded-md"
>
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<div className={`p-1 rounded flex-shrink-0 ${rate.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
<Truck className="h-3.5 w-3.5 md:h-4 md:w-4" />
</div>
<div className="flex-1 min-w-0">
<span
className="text-xs md:text-sm font-medium line-clamp-1"
dangerouslySetInnerHTML={{ __html: rate.name }}
/>
{rate.transitTime && (
<span className="text-xs text-muted-foreground ml-2">
{rate.transitTime}
</span>
)}
{rate.condition && (
<span className="text-xs text-muted-foreground ml-2">
{rate.condition}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
<span
className="text-xs md:text-sm font-semibold whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: rate.price }}
/>
<ToggleField
id={`${zone.id}-${rate.instance_id}`}
label=""
checked={rate.enabled}
onCheckedChange={(checked) => handleToggle(zone.id, rate.instance_id, checked)}
disabled={togglingMethod === `${zone.id}-${rate.instance_id}`}
/>
</div>
</div>
))}
</div>
</div>
))}
<Button
variant="outline"
className="w-full"
asChild
>
<a href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=shipping`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
{__('Manage Zones in WooCommerce')}
</a>
</Button>
</div>
)}
</SettingsCard>
{/* Help Card */}
<div className="bg-muted/50 border rounded-lg p-4">
<p className="text-sm font-medium mb-2">💡 {__('Shipping tips')}</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> {__('Offer free shipping for orders above a certain amount to increase average order value')}</li>
<li> {__('Provide multiple shipping options to give customers flexibility')}</li>
<li> {__('Set realistic delivery estimates to manage customer expectations')}</li>
<li> {__('Configure detailed shipping settings in WooCommerce for full control')}</li>
</ul>
</div>
{/* Settings Modal/Drawer */}
{selectedZone && (
isDesktop ? (
<Dialog open={!!selectedZone} onOpenChange={(open) => !open && setSelectedZone(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>{selectedZone.name}</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
{selectedZone.regions}
</p>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 min-h-0">
<div className="space-y-4">
{/* Add Delivery Option Button */}
{!showAvailableMethods ? (
<Button
variant="outline"
className="w-full"
onClick={() => setShowAvailableMethods(true)}
>
<Plus className="h-4 w-4 mr-2" />
{__('Add Delivery Option')}
</Button>
) : (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">{__('Available Delivery Options')}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAvailableMethods(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{availableMethods.map((method: any) => (
<button
key={method.id}
onClick={() => {
handleAddMethod(method.id);
setShowAvailableMethods(false);
}}
disabled={addMethodMutation.isPending}
className="w-full text-left p-3 border rounded-lg hover:border-primary hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="font-medium text-sm">{method.title}</div>
{method.description && (
<div className="text-xs text-muted-foreground mt-1">
{method.description}
</div>
)}
</button>
))}
</div>
)}
{/* Delivery Options Accordion */}
<Accordion type="single" collapsible value={expandedMethod} onValueChange={(value) => {
setExpandedMethod(value);
if (value) {
const instanceId = parseInt(value);
fetchMethodSettings(instanceId);
}
}}>
{selectedZone.rates?.map((rate: any) => (
<AccordionItem key={rate.id} value={String(rate.instance_id)} className="border rounded-lg mb-2">
<AccordionTrigger className="hover:no-underline px-4">
<div className="flex items-center gap-3 flex-1">
<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>
<div className="flex-1 text-left">
<div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
<span className={`text-xs px-2 py-0.5 rounded-full ${
rate.enabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{rate.enabled ? __('On') : __('Off')}
</span>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
{methodSettings[rate.instance_id] ? (
<div className="space-y-4 pt-2">
<div>
<label className="text-sm font-medium mb-2 block">
{__('Display Name')}
</label>
<input
className="w-full px-3 py-2 border rounded-md"
defaultValue={methodSettings[rate.instance_id].settings?.title?.value || ''}
id={`method-title-${rate.instance_id}`}
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Name shown to customers at checkout')}
</p>
</div>
{methodSettings[rate.instance_id].settings?.cost && (
<div>
<label className="text-sm font-medium mb-2 block">
{methodSettings[rate.instance_id].settings.cost.title || __('Cost')}
</label>
<input
className="w-full px-3 py-2 border rounded-md"
defaultValue={methodSettings[rate.instance_id].settings.cost.value || ''}
id={`method-cost-${rate.instance_id}`}
placeholder={methodSettings[rate.instance_id].settings.cost.placeholder || '0'}
/>
{methodSettings[rate.instance_id].settings.cost.description && (
<p
className="text-xs text-muted-foreground mt-1"
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.cost.description }}
/>
)}
</div>
)}
{methodSettings[rate.instance_id].settings?.min_amount && (
<div>
<label className="text-sm font-medium mb-2 block">
{methodSettings[rate.instance_id].settings.min_amount.title || __('Minimum Order')}
</label>
<input
className="w-full px-3 py-2 border rounded-md"
defaultValue={methodSettings[rate.instance_id].settings.min_amount.value || ''}
id={`method-min-amount-${rate.instance_id}`}
placeholder={methodSettings[rate.instance_id].settings.min_amount.placeholder || '0'}
/>
{methodSettings[rate.instance_id].settings.min_amount.description && (
<p
className="text-xs text-muted-foreground mt-1"
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.min_amount.description }}
/>
)}
</div>
)}
<div className="flex justify-between gap-2 pt-2">
<Button
variant="destructive"
size="sm"
onClick={() => {
handleDeleteMethod(rate.instance_id, rate.title);
setExpandedMethod('');
}}
disabled={deleteMethodMutation.isPending}
>
<Trash2 className="h-4 w-4 mr-1" />
{__('Remove')}
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setExpandedMethod('')}
>
{__('Cancel')}
</Button>
<Button
size="sm"
onClick={() => {
const settings: any = {};
const titleInput = document.getElementById(`method-title-${rate.instance_id}`) as HTMLInputElement;
const costInput = document.getElementById(`method-cost-${rate.instance_id}`) as HTMLInputElement;
const minAmountInput = document.getElementById(`method-min-amount-${rate.instance_id}`) as HTMLInputElement;
if (titleInput) settings.title = titleInput.value;
if (costInput) settings.cost = costInput.value;
if (minAmountInput) settings.min_amount = minAmountInput.value;
updateSettingsMutation.mutate({
zoneId: selectedZone.id,
instanceId: rate.instance_id,
settings
});
}}
disabled={updateSettingsMutation.isPending}
>
{updateSettingsMutation.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-between gap-3">
<Button
variant="outline"
asChild
>
<a href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=shipping&zone_id=${selectedZone.id}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
{__('Edit in WooCommerce')}
</a>
</Button>
<Button onClick={() => setSelectedZone(null)}>
{__('Done')}
</Button>
</div>
</DialogContent>
</Dialog>
) : (
<Drawer open={!!selectedZone} onOpenChange={(open) => !open && setSelectedZone(null)}>
<DrawerContent className="max-h-[90vh] flex flex-col">
<DrawerHeader className="border-b">
<DrawerTitle>{selectedZone.name}</DrawerTitle>
<p className="text-sm text-muted-foreground mt-1">
{selectedZone.regions}
</p>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
<div className="space-y-3">
{/* Add Delivery Option Button */}
{!showAvailableMethods ? (
<Button
variant="outline"
className="w-full"
onClick={() => setShowAvailableMethods(true)}
>
<Plus className="h-4 w-4 mr-2" />
{__('Add Delivery Option')}
</Button>
) : (
<div className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-sm">{__('Available Delivery Options')}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAvailableMethods(false)}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableMethods.map((method: any) => (
<button
key={method.id}
onClick={() => {
handleAddMethod(method.id);
setShowAvailableMethods(false);
}}
disabled={addMethodMutation.isPending}
className="w-full text-left p-2 border rounded-lg hover:border-primary hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="font-medium text-xs">{method.title}</div>
{method.description && (
<div className="text-[10px] text-muted-foreground mt-0.5">
{method.description}
</div>
)}
</button>
))}
</div>
)}
{/* Delivery Options Accordion (Mobile) */}
<Accordion type="single" collapsible value={expandedMethod} onValueChange={(value) => {
setExpandedMethod(value);
if (value) {
const instanceId = parseInt(value);
fetchMethodSettings(instanceId);
}
}}>
{selectedZone.rates?.map((rate: any) => (
<AccordionItem key={rate.id} value={String(rate.instance_id)} className="border rounded-lg mb-2">
<AccordionTrigger className="hover:no-underline px-3 py-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={`p-1.5 rounded-lg flex-shrink-0 ${rate.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
<Truck className="h-3.5 w-3.5" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
<span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${
rate.enabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{rate.enabled ? __('On') : __('Off')}
</span>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
{methodSettings[rate.instance_id] ? (
<div className="space-y-3 pt-2">
<div>
<label className="text-xs font-medium mb-1.5 block">
{__('Display Name')}
</label>
<input
className="w-full px-2.5 py-1.5 text-sm border rounded-md"
defaultValue={methodSettings[rate.instance_id].settings?.title?.value || ''}
id={`method-title-mobile-${rate.instance_id}`}
/>
</div>
{methodSettings[rate.instance_id].settings?.cost && (
<div>
<label className="text-xs font-medium mb-1.5 block">
{methodSettings[rate.instance_id].settings.cost.title || __('Cost')}
</label>
<input
className="w-full px-2.5 py-1.5 text-sm border rounded-md"
defaultValue={methodSettings[rate.instance_id].settings.cost.value || ''}
id={`method-cost-mobile-${rate.instance_id}`}
placeholder={methodSettings[rate.instance_id].settings.cost.placeholder || '0'}
/>
{methodSettings[rate.instance_id].settings.cost.description && (
<p
className="text-xs text-muted-foreground mt-1"
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.cost.description }}
/>
)}
</div>
)}
{methodSettings[rate.instance_id].settings?.min_amount && (
<div>
<label className="text-xs font-medium mb-1.5 block">
{methodSettings[rate.instance_id].settings.min_amount.title || __('Minimum Order')}
</label>
<input
className="w-full px-2.5 py-1.5 text-sm border rounded-md"
defaultValue={methodSettings[rate.instance_id].settings.min_amount.value || ''}
id={`method-min-amount-mobile-${rate.instance_id}`}
placeholder={methodSettings[rate.instance_id].settings.min_amount.placeholder || '0'}
/>
</div>
)}
<div className="flex justify-between gap-2 pt-1">
<Button
variant="destructive"
size="sm"
onClick={() => {
handleDeleteMethod(rate.instance_id, rate.title);
setExpandedMethod('');
}}
disabled={deleteMethodMutation.isPending}
>
<Trash2 className="h-3 w-3 mr-1" />
{__('Remove')}
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setExpandedMethod('')}
>
{__('Cancel')}
</Button>
<Button
size="sm"
onClick={() => {
const settings: any = {};
const titleInput = document.getElementById(`method-title-mobile-${rate.instance_id}`) as HTMLInputElement;
const costInput = document.getElementById(`method-cost-mobile-${rate.instance_id}`) as HTMLInputElement;
const minAmountInput = document.getElementById(`method-min-amount-mobile-${rate.instance_id}`) as HTMLInputElement;
if (titleInput) settings.title = titleInput.value;
if (costInput) settings.cost = costInput.value;
if (minAmountInput) settings.min_amount = minAmountInput.value;
updateSettingsMutation.mutate({
zoneId: selectedZone.id,
instanceId: rate.instance_id,
settings
});
}}
disabled={updateSettingsMutation.isPending}
>
{updateSettingsMutation.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
<div className="px-4 py-4 border-t flex flex-col gap-2">
<Button
variant="outline"
asChild
className="w-full"
>
<a href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=shipping&zone_id=${selectedZone.id}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
{__('Edit in WooCommerce')}
</a>
</Button>
<Button onClick={() => setSelectedZone(null)} className="w-full">
{__('Done')}
</Button>
</div>
</DrawerContent>
</Drawer>
)
)}
{/* Delete Method Confirmation Dialog */}
<AlertDialog open={!!deletingMethod} onOpenChange={() => setDeletingMethod(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete Shipping Method?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to delete')} <strong>{deletingMethod?.name}</strong>?
{' '}{__('This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Zone Confirmation Dialog */}
<AlertDialog open={!!deletingZone} onOpenChange={() => setDeletingZone(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete Shipping Zone?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to delete')} <strong>{deletingZone?.name}</strong>?
{' '}{__('All shipping methods in this zone will also be deleted. This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deletingZone) {
deleteZoneMutation.mutate(deletingZone.id);
}
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{__('Delete Zone')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Add/Edit Zone Dialog */}
<Dialog open={showAddZone || !!editingZone} onOpenChange={(open) => {
if (!open) {
setShowAddZone(false);
setEditingZone(null);
setRegionSearch('');
}
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingZone ? __('Edit Zone') : __('Add Shipping Zone')}</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const zoneName = formData.get('name') as string;
const selectedRegions = formData.getAll('regions').map(code => {
const location = availableLocations.find((l: any) => l.code === code);
return location ? { code: location.code, type: location.type } : null;
}).filter(Boolean);
if (editingZone) {
updateZoneMutation.mutate({
zoneId: editingZone.id,
name: zoneName,
regions: selectedRegions,
});
} else {
createZoneMutation.mutate({
name: zoneName,
regions: selectedRegions,
});
}
}} className="space-y-4">
<div>
<label htmlFor="zone-name" className="text-sm font-medium block mb-2">
{__('Zone Name')}
</label>
<input
id="zone-name"
name="name"
required
defaultValue={editingZone?.name || ''}
placeholder={__('e.g., Domestic, International, Europe')}
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label className="text-sm font-medium block mb-2">
{__('Regions')}
</label>
<p className="text-xs text-muted-foreground mb-3">
{__('Select countries, states, or continents for this zone')}
</p>
{/* Search Filter */}
<input
placeholder={__('Search regions...')}
value={regionSearch}
onChange={(e) => setRegionSearch(e.target.value)}
className="w-full px-3 py-2 border rounded-md mb-2"
/>
<div className="border rounded-md max-h-[300px] overflow-y-auto">
{availableLocations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{__('Loading locations...')}
</div>
) : (
<div className="divide-y">
{availableLocations
.filter((location: any) =>
location.label.toLowerCase().includes(regionSearch.toLowerCase())
)
.map((location: any) => (
<label
key={location.code}
className="flex items-center gap-3 p-3 hover:bg-accent cursor-pointer"
>
<input
type="checkbox"
name="regions"
value={location.code}
defaultChecked={editingZone?.locations?.some((l: any) => l.code === location.code)}
className="rounded"
/>
<span className="text-sm">{location.label}</span>
</label>
))}
{availableLocations.filter((location: any) =>
location.label.toLowerCase().includes(regionSearch.toLowerCase())
).length === 0 && (
<div className="p-4 text-center text-sm text-muted-foreground">
{__('No regions found')}
</div>
)}
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setShowAddZone(false);
setEditingZone(null);
setRegionSearch('');
}}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={createZoneMutation.isPending || updateZoneMutation.isPending}
>
{(createZoneMutation.isPending || updateZoneMutation.isPending) && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
{editingZone ? __('Update Zone') : __('Create Zone')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</SettingsLayout>
);
}