feat: Option B - Marketplace-style simplified shipping UI

Implemented ultra-simple, marketplace-inspired shipping interface!

Key Changes:
 Removed tabs - single view for delivery options
 Removed "Zone Details" tab - not needed
 Updated terminology:
   - "Shipping Methods" → "Delivery Options"
   - "Add Shipping Method" → "Add Delivery Option"
   - "Active/Inactive" badges (no toggles in modal)
 Added Edit button for each delivery option
 Simple settings form (title, cost, min amount)
 Removed technical jargon (no "priority", "instance", etc.)

New User Flow:
1. Main page: See zones with inline toggles
2. Click Edit icon → Modal opens
3. Modal shows:
   - [+ Add Delivery Option] button
   - List of delivery options with:
     * Name + Cost + Status badge
     * Edit button (opens settings)
     * Delete button
4. Click Edit → Simple form:
   - Display Name
   - Cost
   - Minimum Order (if applicable)
5. Save → Done!

Inspired by:
- Shopee: Ultra simple, flat list
- Tokopedia: No complex zones visible
- Lazada: Name + Price + Condition

Result:
 Zero learning curve
 Marketplace-familiar UX
 All WooCommerce power (hidden in backend)
 Perfect for non-tech users

Complexity stays in backend, simplicity for users! 🎯
This commit is contained in:
dwindown
2025-11-09 17:47:31 +07:00
parent 267914dbfe
commit 08a42ee79a

View File

@@ -7,7 +7,6 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Globe, Truck, MapPin, Edit, Trash2, RefreshCw, Loader2, ExternalLink, Settings, Plus, X } from 'lucide-react'; import { Globe, Truck, MapPin, Edit, Trash2, RefreshCw, Loader2, ExternalLink, Settings, Plus, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
@@ -34,8 +33,9 @@ export default function ShippingPage() {
const [togglingMethod, setTogglingMethod] = useState<string | null>(null); const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
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 [activeTab, setActiveTab] = useState('methods');
const [showAddMethod, setShowAddMethod] = useState(false); const [showAddMethod, setShowAddMethod] = useState(false);
const [editingMethod, setEditingMethod] = useState<any | null>(null);
const [showEditDialog, setShowEditDialog] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// Fetch shipping zones from WooCommerce // Fetch shipping zones from WooCommerce
@@ -87,14 +87,38 @@ export default function ShippingPage() {
// Delete shipping method mutation // Delete shipping method mutation
const deleteMethodMutation = useMutation({ const deleteMethodMutation = useMutation({
mutationFn: async ({ zoneId, instanceId }: { zoneId: number; instanceId: number }) => { mutationFn: async ({ zoneId, instanceId }: { zoneId: number; instanceId: number }) => {
return api.delete(`/settings/shipping/zones/${zoneId}/methods/${instanceId}`); return api.del(`/settings/shipping/zones/${zoneId}/methods/${instanceId}`);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] }); queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
toast.success(__('Shipping method deleted successfully')); toast.success(__('Delivery option deleted'));
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error?.message || __('Failed to delete shipping method')); toast.error(error?.message || __('Failed to delete delivery option'));
},
});
// Fetch method settings for editing
const { data: methodSettings, isLoading: isLoadingSettings } = useQuery({
queryKey: ['method-settings', selectedZone?.id, editingMethod?.instance_id],
queryFn: () => api.get(`/settings/shipping/zones/${selectedZone.id}/methods/${editingMethod.instance_id}/settings`),
enabled: !!editingMethod && !!selectedZone,
});
// 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: () => {
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
queryClient.invalidateQueries({ queryKey: ['method-settings'] });
toast.success(__('Settings saved'));
setShowEditDialog(false);
setEditingMethod(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to save settings'));
}, },
}); });
@@ -278,88 +302,65 @@ export default function ShippingPage() {
{selectedZone.regions} {selectedZone.regions}
</p> </p>
</DialogHeader> </DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> <div className="flex-1 overflow-y-auto p-6 min-h-0">
<TabsList className="mx-6 mt-4"> <div className="space-y-4">
<TabsTrigger value="methods">{__('Shipping Methods')}</TabsTrigger> {/* Add Delivery Option Button */}
<TabsTrigger value="details">{__('Zone Details')}</TabsTrigger> <Button
</TabsList> variant="outline"
className="w-full"
<TabsContent value="methods" className="flex-1 overflow-y-auto px-6 pb-6 mt-0"> onClick={() => setShowAddMethod(true)}
<div className="space-y-4 mt-4"> >
{/* Add Method Button */} <Plus className="h-4 w-4 mr-2" />
<Button {__('Add Delivery Option')}
variant="outline" </Button>
className="w-full"
onClick={() => setShowAddMethod(true)}
>
<Plus className="h-4 w-4 mr-2" />
{__('Add Shipping Method')}
</Button>
{/* Methods List */} {/* Delivery Options List */}
<div className="space-y-3"> <div className="space-y-3">
{selectedZone.rates?.map((rate: any) => ( {selectedZone.rates?.map((rate: any) => (
<div key={rate.id} className="border rounded-lg p-4"> <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 mb-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-2">
<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 }} /> <span className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
</div>
<div className="text-sm text-muted-foreground">
<span>{__('Cost')}:</span>{' '}
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className={`text-xs px-2 py-1 rounded-full ${ <span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
<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 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}`}> }`}>
{rate.enabled ? __('Active') : __('Inactive')} {rate.enabled ? __('Active') : __('Inactive')}
</span> </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 className="flex items-center gap-1">
))} <Button
</div> variant="ghost"
</div> size="sm"
</TabsContent> onClick={() => {
setEditingMethod(rate);
<TabsContent value="details" className="flex-1 overflow-y-auto px-6 pb-6 mt-0"> setShowEditDialog(true);
<div className="space-y-4 mt-4"> }}
<div className="bg-muted/50 rounded-lg p-4"> >
<div className="space-y-3"> <Edit className="h-4 w-4" />
<div> </Button>
<p className="text-sm font-medium mb-1">{__('Zone Name')}</p> <Button
<p className="text-sm text-muted-foreground">{selectedZone.name}</p> variant="ghost"
</div> size="sm"
<div> onClick={() => handleDeleteMethod(rate.instance_id)}
<p className="text-sm font-medium mb-1">{__('Regions')}</p> disabled={deleteMethodMutation.isPending}
<p className="text-sm text-muted-foreground">{selectedZone.regions}</p> >
</div> <Trash2 className="h-4 w-4 text-destructive" />
<div> </Button>
<p className="text-sm font-medium mb-1">{__('Zone Order')}</p> </div>
<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> </div>
</div> ))}
<p className="text-sm text-muted-foreground">
{__('To edit zone name, regions, or order, use WooCommerce.')}
</p>
</div> </div>
</TabsContent> </div>
</Tabs> </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">
<Button <Button
variant="outline" variant="outline"
@@ -385,52 +386,64 @@ export default function ShippingPage() {
{selectedZone.regions} {selectedZone.regions}
</p> </p>
</DrawerHeader> </DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-6 min-h-0"> <div className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
<div className="space-y-6"> <div className="space-y-3">
{/* Zone Summary */} {/* Add Delivery Option Button */}
<div className="bg-muted/50 rounded-lg p-4"> <Button
<div className="flex items-center justify-between"> variant="outline"
<div> className="w-full"
<p className="text-sm font-medium">{__('Zone Order')}</p> onClick={() => setShowAddMethod(true)}
<p className="text-xs text-muted-foreground">{__('Priority in shipping calculations')}</p> >
</div> <Plus className="h-4 w-4 mr-2" />
<span className="text-2xl font-bold text-muted-foreground">{selectedZone.order}</span> {__('Add Delivery Option')}
</div> </Button>
</div>
{/* Shipping Methods */} {/* Delivery Options List */}
<div> <div className="space-y-2">
<div className="flex items-center justify-between mb-3"> {selectedZone.rates?.map((rate: any) => (
<h4 className="font-semibold">{__('Shipping Methods')}</h4> <div key={rate.id} className="border rounded-lg p-3">
<span className="text-sm text-muted-foreground"> <div className="flex items-start justify-between gap-2">
{selectedZone.rates?.length} {selectedZone.rates?.length === 1 ? 'method' : 'methods'} <div className="flex-1 min-w-0">
</span> <div className="flex items-center gap-2 mb-1">
</div> <Truck className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<div className="space-y-3"> <span className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
{selectedZone.rates?.map((rate: any) => (
<div key={rate.id} className="border rounded-lg p-3">
<div className="flex items-start justify-between gap-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 text-sm" dangerouslySetInnerHTML={{ __html: rate.name }} />
</div>
<div className="text-sm text-muted-foreground">
<span>{__('Cost')}:</span>{' '}
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
</div>
</div> </div>
<span className={`text-xs px-2 py-1 rounded-full whitespace-nowrap ${ <div className="flex items-center gap-2 text-xs text-muted-foreground">
rate.enabled <span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' <span className={`px-2 py-0.5 rounded-full whitespace-nowrap ${
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' rate.enabled
}`}> ? 'bg-green-100 text-green-700'
{rate.enabled ? __('Active') : __('Inactive')} : 'bg-gray-100 text-gray-600'
</span> }`}>
{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> </div>
</div> </div>
))} </div>
</div> ))}
</div> </div>
</div> </div>
</div> </div>
@@ -454,16 +467,15 @@ export default function ShippingPage() {
) )
)} )}
{/* Add Method Dialog */} {/* Add Delivery Option Dialog */}
<Dialog open={showAddMethod} onOpenChange={setShowAddMethod}> <Dialog open={showAddMethod} onOpenChange={setShowAddMethod}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{__('Add Shipping Method')}</DialogTitle> <DialogTitle>{__('Add Delivery Option')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{__('Select a shipping method to add to this zone:')} {__('Select a delivery option to add:')}</p>
</p>
<div className="space-y-2"> <div className="space-y-2">
{availableMethods.map((method: any) => ( {availableMethods.map((method: any) => (
<button <button
@@ -484,6 +496,110 @@ export default function ShippingPage() {
</div> </div>
</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>
); );
} }