updates on the customer app and map stuck issue
This commit is contained in:
@@ -83,6 +83,8 @@ const AuthenticatedApp = () => {
|
|||||||
now" without a filter being set first. */}
|
now" without a filter being set first. */}
|
||||||
<Route path="/" element={<Navigate to="/doormile/home" replace />} />
|
<Route path="/" element={<Navigate to="/doormile/home" replace />} />
|
||||||
<Route path="/home" element={<Navigate to="/doormile/home" replace />} />
|
<Route path="/home" element={<Navigate to="/doormile/home" replace />} />
|
||||||
|
<Route path="/customers" element={<Navigate to="/doormile/customers" replace />} />
|
||||||
|
<Route path="/customer" element={<Navigate to="/doormile/customers" replace />} />
|
||||||
|
|
||||||
<Route path="/doormile" element={<AdminLayout />}>
|
<Route path="/doormile" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/doormile/home" replace />} />
|
<Route index element={<Navigate to="/doormile/home" replace />} />
|
||||||
|
|||||||
@@ -120,4 +120,39 @@ export const readStoredToken = () => localStorage.getItem(DOORMILE_TOKEN_KEY);
|
|||||||
export const errorMessage = (err, fallback = 'Something went wrong') =>
|
export const errorMessage = (err, fallback = 'Something went wrong') =>
|
||||||
err?.message || err?.response?.data?.message || fallback;
|
err?.message || err?.response?.data?.message || fallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed call explained for the person at the screen, not the developer.
|
||||||
|
*
|
||||||
|
* `action` finishes the sentence "We couldn't …", e.g. 'create this order'.
|
||||||
|
* The server's own message is kept where it describes the user's input
|
||||||
|
* (400/409/422 — e.g. a CityGate pincode rejection), because that is the
|
||||||
|
* part they can fix. Everything else — no connection, a timeout, a 5xx, raw
|
||||||
|
* axios text like "Network Error" — is replaced with what happened and what
|
||||||
|
* to do next.
|
||||||
|
*/
|
||||||
|
export const friendlyErrorMessage = (err, action = 'complete this request') => {
|
||||||
|
const status = err?.httpStatus ?? err?.response?.status;
|
||||||
|
const serverMsg = String(err?.response?.data?.message || (status ? err?.message : '') || '').trim();
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
return `We couldn't ${action} because you're offline. Check your internet connection and try again.`;
|
||||||
|
}
|
||||||
|
if (err?.code === 'ECONNABORTED' || /timeout/i.test(err?.message || '')) {
|
||||||
|
return `We couldn't ${action} — the server took too long to respond. Please try again in a moment.`;
|
||||||
|
}
|
||||||
|
return `We couldn't ${action} because the Doormile server couldn't be reached. Check your internet connection and try again.`;
|
||||||
|
}
|
||||||
|
if (status === 401) return 'Your session has expired. Please sign in again.';
|
||||||
|
if (status === 403) return `You don't have permission to ${action}. Ask your admin for access.`;
|
||||||
|
if (status === 404) return `We couldn't ${action} — the record it refers to no longer exists. Refresh the page and try again.`;
|
||||||
|
if (status === 429) return `Too many requests at once. Wait a few seconds, then try to ${action} again.`;
|
||||||
|
if (status >= 500) {
|
||||||
|
return `We couldn't ${action} because of a problem on our server. Your details are still here — please try again in a minute. If it keeps happening, contact Doormile support.`;
|
||||||
|
}
|
||||||
|
return serverMsg
|
||||||
|
? `We couldn't ${action}: ${serverMsg}`
|
||||||
|
: `We couldn't ${action}. Please check the details you entered and try again.`;
|
||||||
|
};
|
||||||
|
|
||||||
export default doormileAxios;
|
export default doormileAxios;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Mail, Package, Pencil, Phone, Plus, Users } from 'lucide-react';
|
import { Mail, Package, Pencil, Phone, Plus, Users } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -22,22 +22,38 @@ const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.custome
|
|||||||
const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
|
const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
|
||||||
const custPhone = (row) => row.phone || row.contactno || '';
|
const custPhone = (row) => row.phone || row.contactno || '';
|
||||||
const custEmail = (row) => row.email || '';
|
const custEmail = (row) => row.email || '';
|
||||||
|
/** Address accessor for search and preview */
|
||||||
|
const custAddress = (row) => [row.address, row.deliveryaddress, row.suburb, row.city, row.postcode].filter(Boolean).join(', ');
|
||||||
/** Tenant customers carry no booking count; 0 is honest rather than blank. */
|
/** Tenant customers carry no booking count; 0 is honest rather than blank. */
|
||||||
const custBookings = (row) => row.totalbookings ?? 0;
|
const custBookings = (row) => row.totalbookings ?? 0;
|
||||||
|
|
||||||
export default function Customers({ embedded = false }) {
|
const readSavedContacts = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: customers = [], isLoading, isFetching } = useTenantCustomers();
|
|
||||||
const updateCustomer = useUpdateTenantCustomer();
|
|
||||||
|
|
||||||
const [localSavedContacts] = useState(() => {
|
|
||||||
try {
|
try {
|
||||||
const data = localStorage.getItem('doormile_saved_contacts');
|
const data = localStorage.getItem('doormile_saved_contacts');
|
||||||
return data ? JSON.parse(data) : [];
|
return data ? JSON.parse(data) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
export default function Customers({ embedded = false }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: customers = [], isLoading, isFetching } = useTenantCustomers();
|
||||||
|
const updateCustomer = useUpdateTenantCustomer();
|
||||||
|
|
||||||
|
const [localSavedContacts, setLocalSavedContacts] = useState(readSavedContacts);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setLocalSavedContacts(readSavedContacts());
|
||||||
|
window.addEventListener('doormile_saved_contacts_updated', handleUpdate);
|
||||||
|
window.addEventListener('storage', handleUpdate);
|
||||||
|
window.addEventListener('focus', handleUpdate);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('doormile_saved_contacts_updated', handleUpdate);
|
||||||
|
window.removeEventListener('storage', handleUpdate);
|
||||||
|
window.removeEventListener('focus', handleUpdate);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const mergedCustomers = useMemo(() => {
|
const mergedCustomers = useMemo(() => {
|
||||||
const list = [...(customers || [])];
|
const list = [...(customers || [])];
|
||||||
@@ -86,7 +102,7 @@ export default function Customers({ embedded = false }) {
|
|||||||
let list = mergedCustomers;
|
let list = mergedCustomers;
|
||||||
if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email));
|
if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email));
|
||||||
else if (activeFilter === 'withPhone') list = list.filter((row) => Boolean(row.phone));
|
else if (activeFilter === 'withPhone') list = list.filter((row) => Boolean(row.phone));
|
||||||
return list.filter((row) => matchesQuery(row, [custName, custPhone, custEmail], debouncedSearch));
|
return list.filter((row) => matchesQuery(row, [custName, custPhone, custEmail, custAddress], debouncedSearch));
|
||||||
}, [mergedCustomers, activeFilter, debouncedSearch]);
|
}, [mergedCustomers, activeFilter, debouncedSearch]);
|
||||||
|
|
||||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||||
@@ -97,13 +113,39 @@ export default function Customers({ embedded = false }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
|
const id = custId(editRow);
|
||||||
|
// If it's a locally saved contact, update localStorage directly
|
||||||
|
if (String(id).startsWith('saved_')) {
|
||||||
|
const cleanPhone = String(form.phone || '').replace(/\D/g, '').slice(-10);
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem('doormile_saved_contacts') || '[]');
|
||||||
|
const updated = stored.map((item) => {
|
||||||
|
if (item.id === id || String(item.phone || item.contactno).replace(/\D/g, '').slice(-10) === cleanPhone) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
name: form.name,
|
||||||
|
firstname: form.name,
|
||||||
|
phone: form.phone,
|
||||||
|
contactno: form.phone,
|
||||||
|
email: form.email
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
localStorage.setItem('doormile_saved_contacts', JSON.stringify(updated));
|
||||||
|
window.dispatchEvent(new Event('doormile_saved_contacts_updated'));
|
||||||
|
} catch {}
|
||||||
|
setEditRow(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/* The record stores firstname/lastname but the dialog edits one name field.
|
/* The record stores firstname/lastname but the dialog edits one name field.
|
||||||
Splitting on the first space keeps the surname through a round trip
|
Splitting on the first space keeps the surname through a round trip
|
||||||
instead of blanking it. */
|
instead of blanking it. */
|
||||||
const [firstname, ...rest] = String(form.name || '').trim().split(/\s+/);
|
const [firstname, ...rest] = String(form.name || '').trim().split(/\s+/);
|
||||||
updateCustomer.mutate(
|
updateCustomer.mutate(
|
||||||
{
|
{
|
||||||
id: custId(editRow),
|
id,
|
||||||
data: {
|
data: {
|
||||||
firstname: firstname || '',
|
firstname: firstname || '',
|
||||||
lastname: rest.join(' '),
|
lastname: rest.join(' '),
|
||||||
@@ -122,15 +164,28 @@ export default function Customers({ embedded = false }) {
|
|||||||
header: 'Customer',
|
header: 'Customer',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
accessor: custName,
|
accessor: custName,
|
||||||
cell: (row) => (
|
cell: (row) => {
|
||||||
|
const addr = custAddress(row);
|
||||||
|
const isSaved = row.isSavedForLater || String(custId(row)).startsWith('saved_');
|
||||||
|
return (
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<Avatar name={custName(row)} size="sm" />
|
<Avatar name={custName(row)} size="sm" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="truncate text-body-sm font-semibold text-ink-1">{custName(row)}</p>
|
<p className="truncate text-body-sm font-semibold text-ink-1">{custName(row)}</p>
|
||||||
<p className="text-caption text-ink-3">ID #{custId(row) ?? '—'}</p>
|
{isSaved && (
|
||||||
|
<span className="text-[10px] bg-emerald-50 text-emerald-700 border border-emerald-200 px-1.5 py-0.5 rounded font-semibold shrink-0">
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-caption text-ink-3 truncate max-w-xs" title={addr || `ID #${custId(row) ?? '—'}`}>
|
||||||
|
{addr ? `📍 ${addr}` : `ID #${custId(row) ?? '—'}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'contact',
|
key: 'contact',
|
||||||
@@ -219,7 +274,7 @@ export default function Customers({ embedded = false }) {
|
|||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
placeholder="Search by name, phone or email"
|
placeholder="Search by name, phone or email"
|
||||||
count={{ shown: rows.length, total: customers.length, noun: 'customers' }}
|
count={{ shown: rows.length, total: mergedCustomers.length, noun: 'customers' }}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => navigate('/doormile/customer/create')}>
|
<Button onClick={() => navigate('/doormile/customer/create')}>
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> New customer
|
<Plus className="mr-1.5 h-4 w-4" /> New customer
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
|
|||||||
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
|
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
|
||||||
import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
|
import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
// Without Leaflet's stylesheet the tiles have no absolute positioning, so the
|
||||||
|
// route preview rendered as a clump of loose tiles with the markers missing.
|
||||||
|
// It only looked right after visiting a page that happened to load it
|
||||||
|
// (Dispatch, Riders).
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import {
|
import {
|
||||||
createAdminCustomer,
|
createAdminCustomer,
|
||||||
createExpressBooking,
|
createExpressBooking,
|
||||||
@@ -61,6 +66,7 @@ import {
|
|||||||
flowLabel
|
flowLabel
|
||||||
} from '@/lib/orderFlow';
|
} from '@/lib/orderFlow';
|
||||||
import { OpenToast } from '@/components/third-party/OpenToast';
|
import { OpenToast } from '@/components/third-party/OpenToast';
|
||||||
|
import { friendlyErrorMessage } from '@/api/doormile/client';
|
||||||
|
|
||||||
const miniPickupIcon = new L.DivIcon({
|
const miniPickupIcon = new L.DivIcon({
|
||||||
className: 'mini-pickup-icon',
|
className: 'mini-pickup-icon',
|
||||||
@@ -78,13 +84,25 @@ const miniDropIcon = new L.DivIcon({
|
|||||||
|
|
||||||
function MiniRouteBoundsAdjuster({ bounds }) {
|
function MiniRouteBoundsAdjuster({ bounds }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
// Keyed on the coordinates, not the array: the caller builds a fresh array
|
||||||
|
// every render, which re-fit the map on every keystroke in the form.
|
||||||
|
const key = JSON.stringify(bounds);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bounds && bounds.length >= 2) {
|
const fit = () => {
|
||||||
|
if (!bounds || bounds.length < 2) return;
|
||||||
try {
|
try {
|
||||||
|
map.invalidateSize();
|
||||||
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 15 });
|
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 15 });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
};
|
||||||
}, [bounds, map]);
|
fit();
|
||||||
|
// The map mounts while the summary column is still laying out, so Leaflet
|
||||||
|
// measures a wrong size and only draws part of the tiles. Re-measure
|
||||||
|
// whenever the container actually changes size.
|
||||||
|
const observer = new ResizeObserver(fit);
|
||||||
|
observer.observe(map.getContainer());
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [key, map]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +302,7 @@ export default function CreateOrder() {
|
|||||||
const cleanNum = digits(cust.contactno || cust.phone).slice(0, 10);
|
const cleanNum = digits(cust.contactno || cust.phone).slice(0, 10);
|
||||||
const name = (cust.firstname || cust.name || '').trim();
|
const name = (cust.firstname || cust.name || '').trim();
|
||||||
if (!cleanNum || !name) {
|
if (!cleanNum || !name) {
|
||||||
OpenToast('Please enter customer name and phone number to save', 'warning', 2500);
|
OpenToast("To save this contact, enter both the customer's name and their 10-digit phone number.", 'warning', 4000);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +328,7 @@ export default function CreateOrder() {
|
|||||||
const next = [newContact, ...prev.filter((p) => String(p.phone || p.contactno).replace(/\D/g, '').slice(-10) !== cleanNum)];
|
const next = [newContact, ...prev.filter((p) => String(p.phone || p.contactno).replace(/\D/g, '').slice(-10) !== cleanNum)];
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('doormile_saved_contacts', JSON.stringify(next.slice(0, 100)));
|
localStorage.setItem('doormile_saved_contacts', JSON.stringify(next.slice(0, 100)));
|
||||||
|
window.dispatchEvent(new Event('doormile_saved_contacts_updated'));
|
||||||
} catch {}
|
} catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -328,7 +347,7 @@ export default function CreateOrder() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
setIsSavedNow(true);
|
setIsSavedNow(true);
|
||||||
OpenToast(`Saved "${name}" (${cleanNum}) for future orders`, 'success', 3000);
|
OpenToast(`Saved ${name} (${cleanNum}). You can pick them from Saved Contacts on your next order.`, 'success', 3500);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -649,9 +668,9 @@ export default function CreateOrder() {
|
|||||||
|
|
||||||
const name = customer.name || customer.firstname || customer.phone || 'Contact';
|
const name = customer.name || customer.firstname || customer.phone || 'Contact';
|
||||||
if (street && hasCoords) {
|
if (street && hasCoords) {
|
||||||
OpenToast(`${name} — contact and address filled`, 'success', 2500);
|
OpenToast(`${name}'s contact details and delivery address have been filled in.`, 'success', 3000);
|
||||||
} else if (street) {
|
} else if (street) {
|
||||||
OpenToast(`${name} — address filled. Resolving map coordinates…`, 'info', 3000);
|
OpenToast(`${name}'s address has been filled in. Finding it on the map…`, 'info', 3000);
|
||||||
// Automatically attempt to geocode the address to resolve lat/lng if not present
|
// Automatically attempt to geocode the address to resolve lat/lng if not present
|
||||||
geocodeAddress(street)
|
geocodeAddress(street)
|
||||||
.then((place) => {
|
.then((place) => {
|
||||||
@@ -664,12 +683,24 @@ export default function CreateOrder() {
|
|||||||
suburb: curr.suburb || place.suburb || '',
|
suburb: curr.suburb || place.suburb || '',
|
||||||
postcode: curr.postcode || place.postcode || ''
|
postcode: curr.postcode || place.postcode || ''
|
||||||
}));
|
}));
|
||||||
OpenToast('Location pinned automatically from address', 'success', 2500);
|
OpenToast('The delivery address has been found and pinned on the map.', 'success', 3000);
|
||||||
|
} else {
|
||||||
|
OpenToast(
|
||||||
|
"We couldn't find this address on the map. Choose it from the address suggestions, or use the map pin to mark the exact spot.",
|
||||||
|
'warning',
|
||||||
|
6000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
OpenToast(
|
||||||
|
"We couldn't look up this address on the map right now. Choose it from the address suggestions, or use the map pin to mark the exact spot.",
|
||||||
|
'warning',
|
||||||
|
6000
|
||||||
|
);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
OpenToast(`${name} — contact details selected. Please enter delivery address.`, 'warning', 3500);
|
OpenToast(`${name}'s contact details are filled in, but no address is saved for them. Please enter the delivery address.`, 'warning', 5000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -678,46 +709,46 @@ export default function CreateOrder() {
|
|||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (!selectedTenantId) {
|
if (!selectedTenantId) {
|
||||||
OpenToast('Please select a client/tenant', 'warning', 2500);
|
OpenToast('Please choose the client this order is for, at the top of the form.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pickCust.firstname.trim()) {
|
if (!pickCust.firstname.trim()) {
|
||||||
OpenToast('Enter pickup contact name', 'warning', 2500);
|
OpenToast('Pickup contact name is missing. Enter the name of the person handing over the parcel.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pickCust.contactno || pickCust.contactno.length !== 10) {
|
if (!pickCust.contactno || pickCust.contactno.length !== 10) {
|
||||||
OpenToast('Enter a valid 10-digit pickup contact number', 'warning', 2500);
|
OpenToast('Pickup phone number must be exactly 10 digits, without +91 or a leading 0.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pickCust.address.trim()) {
|
if (!pickCust.address.trim()) {
|
||||||
OpenToast('Enter pickup address', 'warning', 2500);
|
OpenToast('Pickup address is missing. Enter where the rider should collect the parcel.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Pickup coordinates check
|
// Pickup coordinates check
|
||||||
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
|
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
|
||||||
OpenToast(
|
OpenToast(
|
||||||
isCustomerPickup
|
isCustomerPickup
|
||||||
? 'Pick the collection address from the suggestions, or drop a map pin, so it has coordinates'
|
? "We can't find the pickup address on the map. Choose it from the address suggestions, or use the map pin to mark the exact spot."
|
||||||
: 'The selected hub has no coordinates — set them on the Hubs page before booking from it',
|
: "The selected hub has no map location saved, so riders can't be routed to it. Add its location on the Hubs page, then book again.",
|
||||||
'warning',
|
'warning',
|
||||||
4000
|
6000
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dropCust.firstname.trim()) {
|
if (!dropCust.firstname.trim()) {
|
||||||
OpenToast('Enter delivery contact name', 'warning', 2500);
|
OpenToast('Delivery contact name is missing. Enter the name of the person receiving the parcel.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dropCust.contactno || dropCust.contactno.length !== 10) {
|
if (!dropCust.contactno || dropCust.contactno.length !== 10) {
|
||||||
OpenToast('Enter a valid 10-digit delivery contact number', 'warning', 2500);
|
OpenToast('Delivery phone number must be exactly 10 digits, without +91 or a leading 0.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dropCust.address.trim()) {
|
if (!dropCust.address.trim()) {
|
||||||
OpenToast('Enter delivery address', 'warning', 2500);
|
OpenToast('Delivery address is missing. Enter where the parcel should be delivered.', 'warning', 4000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dropCust.latitude || !dropCust.longitude) {
|
if (!dropCust.latitude || !dropCust.longitude) {
|
||||||
OpenToast('Please select a valid delivery address with coordinates from the suggestions', 'warning', 3000);
|
OpenToast("We can't find the delivery address on the map. Choose it from the address suggestions, or use the map pin to mark the exact spot.", 'warning', 6000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,14 +814,19 @@ export default function CreateOrder() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['ordersBulk'] });
|
queryClient.invalidateQueries({ queryKey: ['ordersBulk'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['adminCustomers'] });
|
queryClient.invalidateQueries({ queryKey: ['adminCustomers'] });
|
||||||
OpenToast('Order created successfully', 'success', 2500);
|
OpenToast('Order created. You can track it on the Orders page.', 'success', 3000);
|
||||||
navigate('/doormile/orders');
|
navigate('/doormile/orders');
|
||||||
} else {
|
} else {
|
||||||
OpenToast(res?.message || 'Failed to create order', 'error', 3500);
|
OpenToast(
|
||||||
|
res?.message
|
||||||
|
? `We couldn't create this order: ${res.message}`
|
||||||
|
: "We couldn't create this order. Please check the details and try again.",
|
||||||
|
'error',
|
||||||
|
6000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const serverMsg = err?.response?.data?.message || err?.message || 'Cannot create order. Please check inputs.';
|
OpenToast(friendlyErrorMessage(err, 'create this order'), 'error', 7000);
|
||||||
OpenToast(serverMsg, 'error', 4000);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import doormileAxios, {
|
|||||||
clearStoredSession,
|
clearStoredSession,
|
||||||
readStoredUser,
|
readStoredUser,
|
||||||
readStoredToken,
|
readStoredToken,
|
||||||
errorMessage
|
errorMessage,
|
||||||
|
friendlyErrorMessage
|
||||||
} from '@/api/doormile/client';
|
} from '@/api/doormile/client';
|
||||||
|
|
||||||
const handlers = axios.__handlers;
|
const handlers = axios.__handlers;
|
||||||
@@ -314,6 +315,46 @@ describe('doormile API client', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('friendlyErrorMessage', () => {
|
||||||
|
const withStatus = (body, status) => Object.defineProperty(body, 'httpStatus', { value: status });
|
||||||
|
|
||||||
|
it('should explain an unreachable server instead of showing "Network Error"', () => {
|
||||||
|
const msg = friendlyErrorMessage({ message: 'Network Error' }, 'create this order');
|
||||||
|
expect(msg).toMatch(/^We couldn't create this order because the Doormile server couldn't be reached/);
|
||||||
|
expect(msg).not.toMatch(/Network Error/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should explain a timeout', () => {
|
||||||
|
expect(friendlyErrorMessage({ code: 'ECONNABORTED', message: 'timeout of 0ms exceeded' }, 'create this order'))
|
||||||
|
.toMatch(/took too long to respond/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the server reason for a validation failure, since the user can fix it', () => {
|
||||||
|
expect(friendlyErrorMessage(withStatus({ message: 'Pickup pincode 110001 is outside our service cities' }, 400), 'create this order'))
|
||||||
|
.toBe("We couldn't create this order: Pickup pincode 110001 is outside our service cities");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide server internals behind a plain message on a 5xx', () => {
|
||||||
|
const msg = friendlyErrorMessage(withStatus({ message: 'relation "bookings" does not exist' }, 500), 'create this order');
|
||||||
|
expect(msg).toMatch(/problem on our server/);
|
||||||
|
expect(msg).not.toMatch(/relation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[401, /session has expired/],
|
||||||
|
[403, /don't have permission to create this order/],
|
||||||
|
[404, /no longer exists/],
|
||||||
|
[429, /Too many requests/]
|
||||||
|
])('should explain HTTP %i', (status, pattern) => {
|
||||||
|
expect(friendlyErrorMessage(withStatus({}, status), 'create this order')).toMatch(pattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to a check-your-details message on a 4xx with no reason', () => {
|
||||||
|
expect(friendlyErrorMessage(withStatus({}, 422), 'create this order'))
|
||||||
|
.toBe("We couldn't create this order. Please check the details you entered and try again.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('module surface', () => {
|
describe('module surface', () => {
|
||||||
it('should export the configured axios instance as the default', () => {
|
it('should export the configured axios instance as the default', () => {
|
||||||
expect(doormileAxios).toBe(axios.__instance);
|
expect(doormileAxios).toBe(axios.__instance);
|
||||||
|
|||||||
Reference in New Issue
Block a user