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:
dwindown
2025-11-09 20:56:34 +07:00
parent 08a42ee79a
commit 31f1a9dae1

View File

@@ -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>
); );
} }