diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx
index 2c95ed1..35c0b26 100644
--- a/src/pages/doormile/orders/CreateOrder.jsx
+++ b/src/pages/doormile/orders/CreateOrder.jsx
@@ -192,18 +192,47 @@ export default function CreateOrder() {
const [searchCustQuery, setSearchCustQuery] = useState('');
// Local persistent saved contacts for new/recent customers
- const [savedContacts, setSavedContacts] = useState(() => {
+ const readSavedContacts = () => {
try {
const data = localStorage.getItem('doormile_saved_contacts');
return data ? JSON.parse(data) : [];
} catch {
return [];
}
- });
+ };
+ const [savedContacts, setSavedContacts] = useState(readSavedContacts);
+
+ // Re-read the browser copy when it changes elsewhere. It used to be read
+ // once, on mount: a contact saved in another tab never appeared in a Create
+ // Order tab that was already open (clicking "Create order" again doesn't
+ // remount it). `storage` fires for other tabs, the custom event for this
+ // one, and focus covers anything missed while the tab was in the background.
+ useEffect(() => {
+ const refresh = () => setSavedContacts(readSavedContacts());
+ const onStorage = (e) => {
+ if (!e.key || e.key === 'doormile_saved_contacts') refresh();
+ };
+ window.addEventListener('storage', onStorage);
+ window.addEventListener('doormile_saved_contacts_updated', refresh);
+ window.addEventListener('focus', refresh);
+ return () => {
+ window.removeEventListener('storage', onStorage);
+ window.removeEventListener('doormile_saved_contacts_updated', refresh);
+ window.removeEventListener('focus', refresh);
+ };
+ }, []);
const [saveForLater, setSaveForLater] = useState(true);
const [isSavedNow, setIsSavedNow] = useState(false);
+ // "Saved" describes the contact currently in the form. Editing the name,
+ // phone or address makes it a different contact, so the button goes back to
+ // "Save Contact" — it used to stay "Saved" and suggest the new details were
+ // already stored when they weren't.
+ useEffect(() => {
+ setIsSavedNow(false);
+ }, [dropCust.firstname, dropCust.contactno, dropCust.address]);
+
// Map pin picker modal target ('pickup' | 'drop' | null)
const [mapPickerTarget, setMapPickerTarget] = useState(null);
@@ -246,14 +275,34 @@ export default function CreateOrder() {
[hubList, tenantLocations]
);
- const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
+ const { data: customerList = [], isLoading: isAdminCustomersLoading } = useQuery({
queryKey: ['adminCustomers'],
queryFn: getAdminCustomers
});
+ // The store "save for later" writes to (POST /admin/tenantcustomers) and the
+ // Customers page reads. The picker used to read only /admin/customers — the
+ // customer-app accounts — so a contact saved on the server never showed up
+ // here; only this browser's local copy did, which another browser, profile
+ // or incognito window can't see. Same query key as the Customers page, so
+ // the two share one cached list.
+ const { data: tenantCustomerList = [], isLoading: isTenantCustomersLoading } = useQuery({
+ queryKey: ['doormile', 'tenant-customers'],
+ queryFn: getTenantCustomers,
+ staleTime: 60_000
+ });
+ const isCustomersLoading = isAdminCustomersLoading || isTenantCustomersLoading;
+
// Combined customers: Merge server customers with locally saved/recent customer contacts
const allCustomers = useMemo(() => {
- const list = [...(customerList || [])];
+ // Tenant customers carry firstname/lastname; the picker renders `name`.
+ const savedOnServer = (tenantCustomerList || []).map((c) => ({
+ ...c,
+ name: c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || undefined,
+ phone: c.phone || c.contactno,
+ isSavedForLater: true
+ }));
+ const list = [...savedOnServer, ...(customerList || [])];
const seenPhones = new Set();
const result = [];
@@ -276,7 +325,7 @@ export default function CreateOrder() {
result.push(c);
}
return result;
- }, [customerList, savedContacts]);
+ }, [customerList, tenantCustomerList, savedContacts]);
const matchedCustomer = useMemo(() => {
const cleanPhone = String(dropCust.contactno || '').replace(/\D/g, '');
@@ -1818,7 +1867,7 @@ export default function CreateOrder() {
const hasPin = Boolean(cust.latitude && cust.longitude);
return (
diff --git a/tests/integration/createOrderSavedContacts.test.jsx b/tests/integration/createOrderSavedContacts.test.jsx
new file mode 100644
index 0000000..ccce255
--- /dev/null
+++ b/tests/integration/createOrderSavedContacts.test.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+
+/**
+ * "Save for later" on Create Order, then Saved Contacts in another tab.
+ *
+ * The contact didn't show up, for two reasons this pins:
+ * 1. The picker read only /admin/customers (customer-app accounts), while
+ * "save for later" writes to /admin/tenantcustomers — so a contact saved
+ * on the server never appeared; only the saving browser's local copy did.
+ * 2. The local copy was read once, on mount, so a Create Order tab that was
+ * already open never saw a contact saved in another tab.
+ */
+
+jest.mock('lucide-react', () =>
+ new Proxy({}, {
+ get: (_t, prop) => (prop === '__esModule' ? true : (props) => ),
+ })
+);
+jest.mock('leaflet', () => ({ __esModule: true, default: { DivIcon: function DivIcon() {} } }));
+jest.mock('react-leaflet', () => ({
+ MapContainer: () => null, Marker: () => null, Polyline: () => null, TileLayer: () => null, useMap: () => ({}),
+}));
+jest.mock('@/components/doormile/AddressAutocomplete', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/components/doormile/MapPinPickerModal', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/components/doormile/HubLocationSelect', () => ({ HubLocationSelect: () => null }));
+jest.mock('@/lib/doormileHooks', () => ({ useHubs: () => ({ data: [] }) }));
+jest.mock('@/lib/geocodingService', () => ({ geocodeAddress: jest.fn(), saveRecentAddress: jest.fn() }));
+jest.mock('@/lib/distance', () => ({
+ calculateDrivingDistance: jest.fn(() => Promise.resolve(null)),
+ calculateDrivingRoute: jest.fn(() => Promise.resolve(null)),
+ calculateTotalCharge: jest.fn(() => 0),
+}));
+jest.mock('@/components/third-party/OpenToast', () => ({ OpenToast: jest.fn() }));
+jest.mock('@/api/doormile/endpoints', () => ({
+ createExpressBooking: jest.fn(),
+ createTenantCustomer: jest.fn(),
+ getAdminCustomers: jest.fn(),
+ getAdminPricing: jest.fn(() => Promise.resolve([])),
+ getAdminTenants: jest.fn(() => Promise.resolve([])),
+ getTenantCustomers: jest.fn(),
+ getTenantLocations: jest.fn(() => Promise.resolve([])),
+}));
+
+import CreateOrder from '@/pages/doormile/orders/CreateOrder';
+import { getAdminCustomers, getTenantCustomers } from '@/api/doormile/endpoints';
+
+const renderPage = () => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+
+ );
+};
+
+const openSavedContacts = async () => {
+ fireEvent.click(screen.getAllByText('Saved Contacts')[0].closest('button'));
+ const heading = await screen.findByRole('heading', { name: 'Saved Contacts' });
+ return heading.closest('div.bg-white');
+};
+
+beforeEach(() => {
+ localStorage.clear();
+ getAdminCustomers.mockResolvedValue([
+ { appcustomerid: 7, name: 'App Customer', phone: '9111111111' },
+ ]);
+ getTenantCustomers.mockResolvedValue([]);
+});
+
+describe('Create Order — Saved Contacts', () => {
+ it('shows a contact saved on the server, which this browser has never seen', async () => {
+ // Nothing in localStorage: this is the "other browser / incognito" case.
+ getTenantCustomers.mockResolvedValue([
+ { tenantcustomerid: 42, firstname: 'Test', lastname: 'Save Contact', phone: '9000000001', address: '12 MG Road' },
+ ]);
+
+ renderPage();
+ const modal = await openSavedContacts();
+
+ await waitFor(() => expect(within(modal).getByText('Test Save Contact')).toBeInTheDocument());
+ expect(within(modal).getByText('(9000000001)')).toBeInTheDocument();
+ // The customer-app accounts are still listed alongside.
+ expect(within(modal).getByText('App Customer')).toBeInTheDocument();
+ });
+
+ it('picks up a contact saved in another tab without reloading the page', async () => {
+ renderPage();
+ let modal = await openSavedContacts();
+ await waitFor(() => expect(within(modal).getByText('App Customer')).toBeInTheDocument());
+ expect(within(modal).queryByText('Other Tab Contact')).not.toBeInTheDocument();
+
+ // Another tab saves a contact: it writes localStorage, and the browser
+ // fires `storage` in every OTHER tab.
+ act(() => {
+ localStorage.setItem(
+ 'doormile_saved_contacts',
+ JSON.stringify([{ id: 'saved_9000000002', name: 'Other Tab Contact', phone: '9000000002', isSavedForLater: true }])
+ );
+ window.dispatchEvent(new StorageEvent('storage', { key: 'doormile_saved_contacts' }));
+ });
+
+ modal = screen.getByRole('heading', { name: 'Saved Contacts' }).closest('div.bg-white');
+ await waitFor(() => expect(within(modal).getByText('Other Tab Contact')).toBeInTheDocument());
+ });
+
+ it('lists a number once when it is both saved locally and on the server', async () => {
+ localStorage.setItem(
+ 'doormile_saved_contacts',
+ JSON.stringify([{ id: 'saved_9000000001', name: 'Test Save Contact', phone: '9000000001', isSavedForLater: true }])
+ );
+ getTenantCustomers.mockResolvedValue([
+ { tenantcustomerid: 42, firstname: 'Test', lastname: 'Save Contact', phone: '9000000001' },
+ ]);
+
+ renderPage();
+ const modal = await openSavedContacts();
+ await waitFor(() => expect(within(modal).getByText('App Customer')).toBeInTheDocument());
+ expect(within(modal).getAllByText('(9000000001)')).toHaveLength(1);
+ });
+});