fix: Mobile accordion + deduplicate shipping methods

Fixes:
 Issue #2: Mobile drawer now uses accordion (no nested modals)
 Issue #3: Duplicate "Local pickup" - now shows as:
   - Local pickup
   - Local pickup (local_pickup_plus)

Changes:
- Mobile drawer matches desktop accordion pattern
- Smaller text/spacing for mobile
- Deduplication logic in backend API
- Adds method ID suffix for duplicate titles

Result:
 No modal-over-modal on any device
 Consistent UX desktop/mobile
 Clear distinction between similar methods
This commit is contained in:
dwindown
2025-11-09 20:58:49 +07:00
parent 31f1a9dae1
commit e00719e41b
2 changed files with 139 additions and 44 deletions

View File

@@ -502,53 +502,137 @@ export default function ShippingPage() {
{__('Add Delivery Option')}
</Button>
{/* Delivery Options List */}
<div className="space-y-2">
{/* 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) => (
<div key={rate.id} className="border rounded-lg p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<AccordionItem key={rate.id} value={String(rate.instance_id)} className="border rounded-lg mb-2">
<div className="flex items-center justify-between pr-2">
<AccordionTrigger className="flex-1 hover:no-underline px-3 py-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Truck className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
<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>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
<span className={`px-2 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 className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
setEditingMethod(rate);
setShowEditDialog(true);
}}
>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleDeleteMethod(rate.instance_id)}
disabled={deleteMethodMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</AccordionTrigger>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleDeleteMethod(rate.instance_id);
}}
disabled={deleteMethodMutation.isPending}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
</div>
<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-end gap-2 pt-1">
<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 className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</AccordionContent>
</AccordionItem>
))}
</div>
</Accordion>
</div>
</div>
<div className="px-4 py-4 border-t flex flex-col gap-2">