diff --git a/src/App.jsx b/src/App.jsx
index dedfbd3..adf35c4 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -83,6 +83,8 @@ const AuthenticatedApp = () => {
now" without a filter being set first. */}
} />
} />
+ } />
+ } />
}>
} />
diff --git a/src/api/doormile/client.js b/src/api/doormile/client.js
index 87e04a0..ca9ab6e 100644
--- a/src/api/doormile/client.js
+++ b/src/api/doormile/client.js
@@ -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;
diff --git a/src/pages/doormile/customers/Customers.jsx b/src/pages/doormile/customers/Customers.jsx
index 324d58d..5fbfa2d 100644
--- a/src/pages/doormile/customers/Customers.jsx
+++ b/src/pages/doormile/customers/Customers.jsx
@@ -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) => (
-
-
-
-
{custName(row)}
-
ID #{custId(row) ?? '—'}
+ cell: (row) => {
+ const addr = custAddress(row);
+ const isSaved = row.isSavedForLater || String(custId(row)).startsWith('saved_');
+ return (
+
+
+
+
+
{custName(row)}
+ {isSaved && (
+
+ Saved
+
+ )}
+
+
+ {addr ? `📍 ${addr}` : `ID #${custId(row) ?? '—'}`}
+
+
-
- ),
+ );
+ },
},
{
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={