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:
@@ -7,7 +7,6 @@ import { ToggleField } from './components/ToggleField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
@@ -34,8 +33,9 @@ export default function ShippingPage() {
|
||||
const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
|
||||
const [selectedZone, setSelectedZone] = useState<any | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('methods');
|
||||
const [showAddMethod, setShowAddMethod] = useState(false);
|
||||
const [editingMethod, setEditingMethod] = useState<any | null>(null);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
// Fetch shipping zones from WooCommerce
|
||||
@@ -87,14 +87,38 @@ export default function ShippingPage() {
|
||||
// Delete shipping method mutation
|
||||
const deleteMethodMutation = useMutation({
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shipping-zones'] });
|
||||
toast.success(__('Shipping method deleted successfully'));
|
||||
toast.success(__('Delivery option deleted'));
|
||||
},
|
||||
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,47 +302,50 @@ export default function ShippingPage() {
|
||||
{selectedZone.regions}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="mx-6 mt-4">
|
||||
<TabsTrigger value="methods">{__('Shipping Methods')}</TabsTrigger>
|
||||
<TabsTrigger value="details">{__('Zone Details')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="methods" className="flex-1 overflow-y-auto px-6 pb-6 mt-0">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Add Method Button */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||
<div className="space-y-4">
|
||||
{/* Add Delivery Option Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowAddMethod(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{__('Add Shipping Method')}
|
||||
{__('Add Delivery Option')}
|
||||
</Button>
|
||||
|
||||
{/* Methods List */}
|
||||
{/* Delivery Options List */}
|
||||
<div className="space-y-3">
|
||||
{selectedZone.rates?.map((rate: any) => (
|
||||
<div key={rate.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<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">
|
||||
<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" />
|
||||
<span className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>{__('Cost')}:</span>{' '}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
rate.enabled
|
||||
? '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'
|
||||
}`}>
|
||||
{rate.enabled ? __('Active') : __('Inactive')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingMethod(rate);
|
||||
setShowEditDialog(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -333,33 +360,7 @@ export default function ShippingPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="details" className="flex-1 overflow-y-auto px-6 pb-6 mt-0">
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">{__('Zone Name')}</p>
|
||||
<p className="text-sm text-muted-foreground">{selectedZone.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">{__('Regions')}</p>
|
||||
<p className="text-sm text-muted-foreground">{selectedZone.regions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">{__('Zone Order')}</p>
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('To edit zone name, regions, or order, use WooCommerce.')}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="px-6 py-4 border-t flex justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -385,55 +386,67 @@ export default function ShippingPage() {
|
||||
{selectedZone.regions}
|
||||
</p>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 min-h-0">
|
||||
<div className="space-y-6">
|
||||
{/* Zone Summary */}
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{__('Zone Order')}</p>
|
||||
<p className="text-xs text-muted-foreground">{__('Priority in shipping calculations')}</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-muted-foreground">{selectedZone.order}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Methods */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold">{__('Shipping Methods')}</h4>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedZone.rates?.length} {selectedZone.rates?.length === 1 ? 'method' : 'methods'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
|
||||
<div className="space-y-3">
|
||||
{/* Add Delivery Option Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowAddMethod(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{__('Add Delivery Option')}
|
||||
</Button>
|
||||
|
||||
{/* Delivery Options List */}
|
||||
<div className="space-y-2">
|
||||
{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-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 }} />
|
||||
<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>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>{__('Cost')}:</span>{' '}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full whitespace-nowrap ${
|
||||
<span className={`px-2 py-0.5 rounded-full whitespace-nowrap ${
|
||||
rate.enabled
|
||||
? '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-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{rate.enabled ? __('Active') : __('Inactive')}
|
||||
{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 className="px-4 py-4 border-t flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -454,16 +467,15 @@ export default function ShippingPage() {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Add Method Dialog */}
|
||||
{/* Add Delivery Option Dialog */}
|
||||
<Dialog open={showAddMethod} onOpenChange={setShowAddMethod}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Add Shipping Method')}</DialogTitle>
|
||||
<DialogTitle>{__('Add Delivery Option')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Select a shipping method to add to this zone:')}
|
||||
</p>
|
||||
{__('Select a delivery option to add:')}</p>
|
||||
<div className="space-y-2">
|
||||
{availableMethods.map((method: any) => (
|
||||
<button
|
||||
@@ -484,6 +496,110 @@ export default function ShippingPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user