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. */}
|
||||
<Route path="/" 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 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') =>
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Package, Pencil, Phone, Plus, Users } from 'lucide-react';
|
||||
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 custPhone = (row) => row.phone || row.contactno || '';
|
||||
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. */
|
||||
const custBookings = (row) => row.totalbookings ?? 0;
|
||||
|
||||
const readSavedContacts = () => {
|
||||
try {
|
||||
const data = localStorage.getItem('doormile_saved_contacts');
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export default function Customers({ embedded = false }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: customers = [], isLoading, isFetching } = useTenantCustomers();
|
||||
const updateCustomer = useUpdateTenantCustomer();
|
||||
|
||||
const [localSavedContacts] = useState(() => {
|
||||
try {
|
||||
const data = localStorage.getItem('doormile_saved_contacts');
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
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 list = [...(customers || [])];
|
||||
@@ -86,7 +102,7 @@ export default function Customers({ embedded = false }) {
|
||||
let list = mergedCustomers;
|
||||
if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email));
|
||||
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]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
@@ -97,13 +113,39 @@ export default function Customers({ embedded = false }) {
|
||||
};
|
||||
|
||||
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.
|
||||
Splitting on the first space keeps the surname through a round trip
|
||||
instead of blanking it. */
|
||||
const [firstname, ...rest] = String(form.name || '').trim().split(/\s+/);
|
||||
updateCustomer.mutate(
|
||||
{
|
||||
id: custId(editRow),
|
||||
id,
|
||||
data: {
|
||||
firstname: firstname || '',
|
||||
lastname: rest.join(' '),
|
||||
@@ -122,15 +164,28 @@ export default function Customers({ embedded = false }) {
|
||||
header: 'Customer',
|
||||
sortable: true,
|
||||
accessor: custName,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar name={custName(row)} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
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">
|
||||
<Avatar name={custName(row)} size="sm" />
|
||||
<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>
|
||||
{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>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
@@ -219,7 +274,7 @@ export default function Customers({ embedded = false }) {
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
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={
|
||||
<Button onClick={() => navigate('/doormile/customer/create')}>
|
||||
<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 { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-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 {
|
||||
createAdminCustomer,
|
||||
createExpressBooking,
|
||||
@@ -61,6 +66,7 @@ import {
|
||||
flowLabel
|
||||
} from '@/lib/orderFlow';
|
||||
import { OpenToast } from '@/components/third-party/OpenToast';
|
||||
import { friendlyErrorMessage } from '@/api/doormile/client';
|
||||
|
||||
const miniPickupIcon = new L.DivIcon({
|
||||
className: 'mini-pickup-icon',
|
||||
@@ -78,13 +84,25 @@ const miniDropIcon = new L.DivIcon({
|
||||
|
||||
function MiniRouteBoundsAdjuster({ bounds }) {
|
||||
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(() => {
|
||||
if (bounds && bounds.length >= 2) {
|
||||
const fit = () => {
|
||||
if (!bounds || bounds.length < 2) return;
|
||||
try {
|
||||
map.invalidateSize();
|
||||
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 15 });
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -284,7 +302,7 @@ export default function CreateOrder() {
|
||||
const cleanNum = digits(cust.contactno || cust.phone).slice(0, 10);
|
||||
const name = (cust.firstname || cust.name || '').trim();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
try {
|
||||
localStorage.setItem('doormile_saved_contacts', JSON.stringify(next.slice(0, 100)));
|
||||
window.dispatchEvent(new Event('doormile_saved_contacts_updated'));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
@@ -328,7 +347,7 @@ export default function CreateOrder() {
|
||||
}).catch(() => {});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -649,9 +668,9 @@ export default function CreateOrder() {
|
||||
|
||||
const name = customer.name || customer.firstname || customer.phone || 'Contact';
|
||||
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) {
|
||||
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
|
||||
geocodeAddress(street)
|
||||
.then((place) => {
|
||||
@@ -664,12 +683,24 @@ export default function CreateOrder() {
|
||||
suburb: curr.suburb || place.suburb || '',
|
||||
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 {
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Pickup coordinates check
|
||||
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
|
||||
OpenToast(
|
||||
isCustomerPickup
|
||||
? 'Pick the collection address from the suggestions, or drop a map pin, so it has coordinates'
|
||||
: 'The selected hub has no coordinates — set them on the Hubs page before booking from it',
|
||||
? "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 map location saved, so riders can't be routed to it. Add its location on the Hubs page, then book again.",
|
||||
'warning',
|
||||
4000
|
||||
6000
|
||||
);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -783,14 +814,19 @@ export default function CreateOrder() {
|
||||
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['ordersBulk'] });
|
||||
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');
|
||||
} 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) {
|
||||
const serverMsg = err?.response?.data?.message || err?.message || 'Cannot create order. Please check inputs.';
|
||||
OpenToast(serverMsg, 'error', 4000);
|
||||
OpenToast(friendlyErrorMessage(err, 'create this order'), 'error', 7000);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ import doormileAxios, {
|
||||
clearStoredSession,
|
||||
readStoredUser,
|
||||
readStoredToken,
|
||||
errorMessage
|
||||
errorMessage,
|
||||
friendlyErrorMessage
|
||||
} from '@/api/doormile/client';
|
||||
|
||||
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', () => {
|
||||
it('should export the configured axios instance as the default', () => {
|
||||
expect(doormileAxios).toBe(axios.__instance);
|
||||
|
||||
Reference in New Issue
Block a user