fix: Replace nested modal with accordion (desktop) + HTML rendering
Fixes: ✅ Issue #1: HTML rendering in descriptions (dangerouslySetInnerHTML) ✅ Issue #2: Nested modal UX - replaced with Accordion (desktop) Changes: - Removed Edit button → Click to expand accordion - Settings form appears inline (no nested dialog) - Smooth expand/collapse animation - Delete button stays visible - Loading spinner while fetching settings Pattern: 🚚 Free Shipping [On] [▼] [Delete] └─ (Expanded) Settings form here Benefits: ✅ No modal-over-modal ✅ Faster editing (no dialog open/close) ✅ See all options while editing one ✅ Matches Shopee/Tokopedia UX Mobile drawer: TODO (next commit)
This commit is contained in:
@@ -7,7 +7,8 @@ import { ToggleField } from './components/ToggleField';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
|
||||||
import { Globe, Truck, MapPin, Edit, Trash2, RefreshCw, Loader2, ExternalLink, Settings, Plus, X } from 'lucide-react';
|
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 { toast } from 'sonner';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||||
@@ -34,8 +35,8 @@ export default function ShippingPage() {
|
|||||||
const [selectedZone, setSelectedZone] = useState<any | null>(null);
|
const [selectedZone, setSelectedZone] = useState<any | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [showAddMethod, setShowAddMethod] = useState(false);
|
const [showAddMethod, setShowAddMethod] = useState(false);
|
||||||
const [editingMethod, setEditingMethod] = useState<any | null>(null);
|
const [expandedMethod, setExpandedMethod] = useState<string>('');
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [methodSettings, setMethodSettings] = useState<Record<string, any>>({});
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
// Fetch shipping zones from WooCommerce
|
// Fetch shipping zones from WooCommerce
|
||||||
@@ -98,24 +99,33 @@ export default function ShippingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch method settings for editing
|
// Fetch method settings when accordion expands
|
||||||
const { data: methodSettings, isLoading: isLoadingSettings } = useQuery({
|
const fetchMethodSettings = async (instanceId: number) => {
|
||||||
queryKey: ['method-settings', selectedZone?.id, editingMethod?.instance_id],
|
if (!selectedZone || methodSettings[instanceId]) return;
|
||||||
queryFn: () => api.get(`/settings/shipping/zones/${selectedZone.id}/methods/${editingMethod.instance_id}/settings`),
|
|
||||||
enabled: !!editingMethod && !!selectedZone,
|
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
|
// Update method settings mutation
|
||||||
const updateSettingsMutation = useMutation({
|
const updateSettingsMutation = useMutation({
|
||||||
mutationFn: async ({ zoneId, instanceId, settings }: { zoneId: number; instanceId: number; settings: any }) => {
|
mutationFn: async ({ zoneId, instanceId, settings }: { zoneId: number; instanceId: number; settings: any }) => {
|
||||||
return api.post(`/settings/shipping/zones/${zoneId}/methods/${instanceId}/settings`, { settings });
|
return api.post(`/settings/shipping/zones/${zoneId}/methods/${instanceId}/settings`, { settings });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
|
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['method-settings'] });
|
// Clear cached settings to force refetch
|
||||||
|
setMethodSettings(prev => {
|
||||||
|
const newSettings = { ...prev };
|
||||||
|
delete newSettings[variables.instanceId];
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
toast.success(__('Settings saved'));
|
toast.success(__('Settings saved'));
|
||||||
setShowEditDialog(false);
|
setExpandedMethod('');
|
||||||
setEditingMethod(null);
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.message || __('Failed to save settings'));
|
toast.error(error?.message || __('Failed to save settings'));
|
||||||
@@ -314,51 +324,145 @@ export default function ShippingPage() {
|
|||||||
{__('Add Delivery Option')}
|
{__('Add Delivery Option')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Delivery Options List */}
|
{/* Delivery Options Accordion */}
|
||||||
<div className="space-y-3">
|
<Accordion type="single" collapsible value={expandedMethod} onValueChange={(value) => {
|
||||||
|
setExpandedMethod(value);
|
||||||
|
if (value) {
|
||||||
|
const instanceId = parseInt(value);
|
||||||
|
fetchMethodSettings(instanceId);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
{selectedZone.rates?.map((rate: any) => (
|
{selectedZone.rates?.map((rate: any) => (
|
||||||
<div key={rate.id} className="border rounded-lg p-4 hover:border-primary/50 transition-colors">
|
<AccordionItem key={rate.id} value={String(rate.instance_id)} className="border rounded-lg mb-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center justify-between pr-4">
|
||||||
<div className="flex-1">
|
<AccordionTrigger className="flex-1 hover:no-underline px-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<Truck className="h-4 w-4 text-muted-foreground" />
|
<Truck className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
<div className="flex-1 text-left">
|
||||||
</div>
|
<div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
rate.enabled
|
rate.enabled
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
? 'bg-green-100 text-green-700'
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
: 'bg-gray-100 text-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
{rate.enabled ? __('Active') : __('Inactive')}
|
{rate.enabled ? __('On') : __('Off')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
setEditingMethod(rate);
|
e.stopPropagation();
|
||||||
setShowEditDialog(true);
|
handleDeleteMethod(rate.instance_id);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteMethod(rate.instance_id)}
|
|
||||||
disabled={deleteMethodMutation.isPending}
|
disabled={deleteMethodMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<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-end gap-2 pt-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>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t flex justify-between gap-3">
|
<div className="px-6 py-4 border-t flex justify-between gap-3">
|
||||||
@@ -497,109 +601,6 @@ export default function ShippingPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Edit Settings Dialog */}
|
|
||||||
<Dialog open={showEditDialog} onOpenChange={(open) => {
|
|
||||||
setShowEditDialog(open);
|
|
||||||
if (!open) setEditingMethod(null);
|
|
||||||
}}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{__('Edit Delivery Option')}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{isLoadingSettings ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : methodSettings ? (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium mb-2 block">
|
|
||||||
{__('Display Name')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border rounded-md"
|
|
||||||
defaultValue={methodSettings.settings?.title?.value || ''}
|
|
||||||
id="method-title"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{__('Name shown to customers at checkout')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{methodSettings.settings?.cost && (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium mb-2 block">
|
|
||||||
{methodSettings.settings.cost.title || __('Cost')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border rounded-md"
|
|
||||||
defaultValue={methodSettings.settings.cost.value || ''}
|
|
||||||
id="method-cost"
|
|
||||||
placeholder={methodSettings.settings.cost.placeholder || '0'}
|
|
||||||
/>
|
|
||||||
{methodSettings.settings.cost.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{methodSettings.settings.cost.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{methodSettings.settings?.min_amount && (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium mb-2 block">
|
|
||||||
{methodSettings.settings.min_amount.title || __('Minimum Order')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border rounded-md"
|
|
||||||
defaultValue={methodSettings.settings.min_amount.value || ''}
|
|
||||||
id="method-min-amount"
|
|
||||||
placeholder={methodSettings.settings.min_amount.placeholder || '0'}
|
|
||||||
/>
|
|
||||||
{methodSettings.settings.min_amount.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{methodSettings.settings.min_amount.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowEditDialog(false)}
|
|
||||||
>
|
|
||||||
{__('Cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const settings: any = {};
|
|
||||||
const titleInput = document.getElementById('method-title') as HTMLInputElement;
|
|
||||||
const costInput = document.getElementById('method-cost') as HTMLInputElement;
|
|
||||||
const minAmountInput = document.getElementById('method-min-amount') 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: editingMethod.instance_id,
|
|
||||||
settings
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={updateSettingsMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateSettingsMutation.isPending ? __('Saving...') : __('Save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user