updates on the customer app and map stuck issue

This commit is contained in:
2026-09-25 15:10:17 +05:30
parent d875900cb0
commit 3e7aaf1e25
5 changed files with 215 additions and 46 deletions

View File

@@ -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 />} />

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);