diff --git a/public/miletruth-logo-dark.png b/public/miletruth-logo-dark.png new file mode 100644 index 0000000..de1b883 Binary files /dev/null and b/public/miletruth-logo-dark.png differ diff --git a/public/miletruth-logo-red.png b/public/miletruth-logo-red.png new file mode 100644 index 0000000..aed5489 Binary files /dev/null and b/public/miletruth-logo-red.png differ diff --git a/public/miletruth-logo.png b/public/miletruth-logo.png new file mode 100644 index 0000000..49d0cc7 Binary files /dev/null and b/public/miletruth-logo.png differ diff --git a/src/assets/brand.js b/src/assets/brand.js index 22cb1f6..8473689 100644 --- a/src/assets/brand.js +++ b/src/assets/brand.js @@ -15,3 +15,6 @@ export const DOORMILE_D_LOGO_URL = '/preloader.png'; /** The white wordmark. Dark backgrounds only. */ export const DOORMILE_WORDMARK_URL = '/doormile-logo.png'; +/** The MileTruth wordmark, already in brand red (#C8102E). Light backgrounds. */ +export const MILETRUTH_WORDMARK_URL = '/miletruth-logo-red.png'; + diff --git a/src/pages/doormile/agents/AgentFlowVisualizer.jsx b/src/pages/doormile/agents/AgentFlowVisualizer.jsx index 53a81e5..374b934 100644 --- a/src/pages/doormile/agents/AgentFlowVisualizer.jsx +++ b/src/pages/doormile/agents/AgentFlowVisualizer.jsx @@ -3,11 +3,13 @@ import PropTypes from 'prop-types'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { CheckCircle2, + ExternalLink, Pause, Play, + Radio, ShieldCheck, - ChevronRight, - ExternalLink, + SkipBack, + SkipForward, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -219,6 +221,7 @@ export default function AgentFlowVisualizer({ const [activeSegment, setActiveSegment] = useState(null); // 'path1', 'path2', 'path3', 'path4', or null const [isPlaying, setIsPlaying] = useState(true); const [isProcessing, setIsProcessing] = useState(false); + const [simSpeed, setSimSpeed] = useState(1); // 0.5 | 1 | 2 // Arriving event in Step 2 (strictly 1 latest event!) const [currentEventFeed, setCurrentEventFeed] = useState({ @@ -420,7 +423,7 @@ export default function AgentFlowVisualizer({ // Step 1: Emitter activates, particle travels from Stage 1 to Stage 2 setIsProcessing(false); timeoutId = setTimeout(() => { - runParticleAlongPath(path1Ref.current, 'path1', 850, () => { + runParticleAlongPath(path1Ref.current, 'path1', Math.round(850 / simSpeed), () => { // Arrives at Stage 2 Event Bus -> tactile arrival ripple triggerImpact(p2.x, p2.y, currentScenario.color); setCurrentEventFeed({ @@ -431,27 +434,27 @@ export default function AgentFlowVisualizer({ }); setStep(2); }); - }, 500); + }, Math.round(500 / simSpeed)); } else if (step === 2) { // Step 2: Information delivered, particle travels from Stage 2 to JARVIS timeoutId = setTimeout(() => { - runParticleAlongPath(path2Ref.current, 'path2', 750, () => { + runParticleAlongPath(path2Ref.current, 'path2', Math.round(750 / simSpeed), () => { // Arrives at JARVIS Orchestrator Apex -> royal indigo routing shockwave! triggerImpact(pJarvis.x, pJarvis.y, '#6366f1'); setStep(3); }); - }, 400); + }, Math.round(400 / simSpeed)); } else if (step === 3) { // Step 3: JARVIS routes to Target Agent, Agent pulses & processes, then routes to Result - runParticleAlongPath(path3Ref.current, 'path3', 600, () => { + runParticleAlongPath(path3Ref.current, 'path3', Math.round(600 / simSpeed), () => { // Arrives at Target Agent -> agent activation ripple! triggerImpact(pAgent.x, pAgent.y, currentScenario.color); setIsProcessing(true); - // Agent processes briefly for 800ms + // Agent processes briefly timeoutId = setTimeout(() => { setIsProcessing(false); // Particle travels from Agent to Result - runParticleAlongPath(path4Ref.current, 'path4', 850, () => { + runParticleAlongPath(path4Ref.current, 'path4', Math.round(850 / simSpeed), () => { // Arrives at Stage 4 Result -> successful resolution ripple! triggerImpact(p4.x, p4.y, currentScenario.color); if (onActivityLogged) { @@ -464,23 +467,66 @@ export default function AgentFlowVisualizer({ } setStep(4); }); - }, 800); + }, Math.round(800 / simSpeed)); }); } else if (step === 4) { - // Step 4: Result reached and activated. Hold for 2.2s in calm state then advance + // Step 4: Result reached and activated. Hold in calm state then advance setParticle((p) => ({ ...p, visible: false })); setActiveSegment(null); timeoutId = setTimeout(() => { setScenarioIndex((prevIdx) => (prevIdx + 1) % SCENARIOS.length); setStep(1); - }, 2200); + }, Math.round(2200 / simSpeed)); } return () => { if (animId) cancelAnimationFrame(animId); if (timeoutId) clearTimeout(timeoutId); }; - }, [step, isPlaying, scenarioIndex, reduce, currentScenario, onActivityLogged]); + }, [step, isPlaying, scenarioIndex, reduce, currentScenario, onActivityLogged, simSpeed]); + + // Scrubber Handlers + const handleStepBack = () => { + setIsPlaying(false); + setActiveSegment(null); + setParticle((p) => ({ ...p, visible: false })); + setIsProcessing(false); + if (step > 1) { + setStep((s) => s - 1); + } else { + setScenarioIndex((prev) => (prev - 1 + SCENARIOS.length) % SCENARIOS.length); + setStep(4); + } + }; + + const handleStepForward = () => { + setIsPlaying(false); + setActiveSegment(null); + setParticle((p) => ({ ...p, visible: false })); + setIsProcessing(false); + if (step < 4) { + setStep((s) => s + 1); + } else { + setScenarioIndex((prev) => (prev + 1) % SCENARIOS.length); + setStep(1); + } + }; + + const handleJumpToStep = (targetStep) => { + setIsPlaying(false); + setActiveSegment(null); + setParticle((p) => ({ ...p, visible: false })); + if (targetStep === 2) { + setCurrentEventFeed({ + text: currentScenario.step1Human, + tech: currentScenario.step1Tech, + time: new Date().toLocaleTimeString('en-GB', { hour12: false }), + color: currentScenario.color, + }); + } + setIsProcessing(targetStep === 3); + setStep(targetStep); + }; // Click handler on agent node -> Toggles dedicated Agent Workflow Panel BELOW the canvas! const handleAgentClick = (agentId) => { @@ -513,23 +559,13 @@ export default function AgentFlowVisualizer({ return (
- {/* ── Scenario Selection Toolbar (Clean & Minimal) ── */} + {/* ── Scenario Selection & Scrubber Controls Toolbar ── */}
-
- - - Workflow Simulation: - - - Watch work flow through Doormile's event bus & agent network - -
- - {/* Clean Scenario Pill Buttons */} - {/* flex-wrap, because four scenario pills plus Pause measure 387px and a - phone viewport is 375 — without it the row pushed the whole page 56px - wide and every screen on the site scrolled sideways, not just this one. */} + {/* Left: Quick-Switch Scenario Pills */}
+ + Scenario: + {SCENARIOS.map((scen, idx) => { const isSelected = scenarioIndex === idx; return ( @@ -538,9 +574,9 @@ export default function AgentFlowVisualizer({ type="button" onClick={() => handleSelectScenario(idx)} className={cn( - 'rounded-lg px-3 py-1 text-xs font-medium transition-all cursor-pointer border', + 'rounded-lg px-2.5 py-1 text-xs font-semibold transition-all cursor-pointer border', isSelected - ? 'border-brand bg-brand-tint text-brand font-bold shadow-2xs' + ? 'border-brand bg-brand-tint text-brand shadow-2xs font-bold' : 'border-border bg-surface text-ink-3 hover:text-ink-1 hover:bg-surface-elevated' )} > @@ -548,28 +584,96 @@ export default function AgentFlowVisualizer({ ); })} +
-
+ {/* Right: Step Scrubber & Speed Controls */} +
+ {/* Unified Executive Control Dock */} +
+ {/* Step-by-Step Scrubber Group */} +
+ - {/* Pause / Play button */} - + + + +
+ +
+ + {/* Quick Stage Jumpers: [01] [02] [03] [04] */} +
+ {[1, 2, 3, 4].map((s) => ( + + ))} +
+ +
+ + {/* Speed Toggle: [0.5x] [1x] [2x] */} +
+ {[0.5, 1, 2].map((sp) => ( + + ))} +
+
@@ -1050,6 +1154,15 @@ export default function AgentFlowVisualizer({
+ + + {/* Live Diagnostics Result Card */} + {probeReport && ( + + {/* Top Metric Cards */} +
+
+ + Round Trip + + + {probeReport.avgRtt} ms + + + {probeReport.jitter} ms jitter + +
+ +
+ + Handshake + + + {probeReport.handshakeMs} ms + + + TLS 1.3 + +
+ +
+ + Packet Loss + + + {probeReport.packetLoss} + + + 10/10 ACKs + +
+
+ + {/* 10-Ping Micro Sparkline Chart */} +
+
+ 10-Packet Latency Burst + 100% Responsive +
+ +
+ {probeReport.pings.map((ping, idx) => { + const heightPct = Math.min(100, Math.max(30, (ping / 30) * 100)); + return ( +
+
+ {/* Hover Tooltip */} +
+ {ping}ms +
+
+ ); + })} +
+
+ + {/* Channel & Target Endpoint Specs */} +
+
+ Target UI Endpoint: + {UI_ENDPOINT} +
+
+ Buffer Load: + {probeReport.bufferLoad} +
+
+ Cipher Suite: + + {probeReport.tlsCipher} + +
+
+ + {/* Raw WebSocket Frame Payload Collapsible */} +
+ + + {showRawFrame && ( +
+
+                            {JSON.stringify(probeReport.rawFrame, null, 2)}
+                          
+ +
+ )} +
+ + )} +
+ + {/* Secondary Technical Metadata */} +
- Technical Specifications (Secondary) + Technical Specifications
@@ -248,17 +494,6 @@ export default function AgentInspectorDrawer({ {agent.load}%
- - {/* Probe Action */} -
diff --git a/src/pages/doormile/customers/Customers.jsx b/src/pages/doormile/customers/Customers.jsx index b902a02..324d58d 100644 --- a/src/pages/doormile/customers/Customers.jsx +++ b/src/pages/doormile/customers/Customers.jsx @@ -30,6 +30,41 @@ export default function Customers({ embedded = false }) { 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 mergedCustomers = useMemo(() => { + const list = [...(customers || [])]; + const seen = new Set(); + const result = []; + + for (const sc of localSavedContacts) { + const p = String(sc.phone || sc.contactno || '').replace(/\D/g, '').slice(-10); + if (p) seen.add(p); + result.push({ + ...sc, + appcustomerid: sc.id || `saved_${p}`, + name: sc.name || sc.firstname, + phone: sc.phone || sc.contactno, + totalbookings: 0 + }); + } + + for (const c of list) { + const p = String(c.phone || c.contactno || '').replace(/\D/g, '').slice(-10); + if (p && seen.has(p)) continue; + if (p) seen.add(p); + result.push(c); + } + return result; + }, [customers, localSavedContacts]); + const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search); const [activeFilter, setActiveFilter] = useState('all'); @@ -38,21 +73,21 @@ export default function Customers({ embedded = false }) { const [form, setForm] = useState({ name: '', phone: '', email: '' }); const stats = useMemo(() => { - const total = customers.length; + const total = mergedCustomers.length; return { total, - withEmail: customers.filter((row) => Boolean(row.email)).length, - withPhone: customers.filter((row) => Boolean(row.phone)).length, - totalBookings: customers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0), + withEmail: mergedCustomers.filter((row) => Boolean(row.email)).length, + withPhone: mergedCustomers.filter((row) => Boolean(row.phone)).length, + totalBookings: mergedCustomers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0), }; - }, [customers]); + }, [mergedCustomers]); const rows = useMemo(() => { - let list = customers; + 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)); - }, [customers, activeFilter, debouncedSearch]); + }, [mergedCustomers, activeFilter, debouncedSearch]); const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key)); diff --git a/src/pages/doormile/home/Home.jsx b/src/pages/doormile/home/Home.jsx index 4543761..2219464 100644 --- a/src/pages/doormile/home/Home.jsx +++ b/src/pages/doormile/home/Home.jsx @@ -26,7 +26,7 @@ import { Users } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; -import { DOORMILE_WORDMARK_URL } from '@/assets/brand'; +import { MILETRUTH_WORDMARK_URL } from '@/assets/brand'; import { cn } from '@/lib/utils'; import { OpenToast } from '@/components/third-party/OpenToast'; import { answerQuestion } from '@/lib/assistant/intents'; @@ -39,10 +39,6 @@ import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assista import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow'; import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns'; -const LOGO_FILTER = { - filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)', -}; - const MILETRUTH_SUGGESTIONS = [ "Repeat yesterday's orders", 'Create a new order', @@ -1130,9 +1126,8 @@ export default function Home() { onClick={resetConversation} > Doormile diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 37e349a..ca3f718 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -2,7 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeft, + Bookmark, Building2, + Check, Clock, Coins, Loader2, @@ -26,6 +28,7 @@ import { HubLocationSelect } from '@/components/doormile/HubLocationSelect'; import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet'; import L from 'leaflet'; import { + createAdminCustomer, createExpressBooking, getAdminCustomers, getAdminPricing, @@ -48,7 +51,7 @@ import { calculateDrivingRoute, calculateTotalCharge } from '@/lib/distance'; -import { saveRecentAddress } from '@/lib/geocodingService'; +import { geocodeAddress, saveRecentAddress } from '@/lib/geocodingService'; import { useHubs } from '@/lib/doormileHooks'; import { PICKUP_SOURCE, @@ -169,6 +172,19 @@ export default function CreateOrder() { const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false); const [searchCustQuery, setSearchCustQuery] = useState(''); + // Local persistent saved contacts for new/recent customers + const [savedContacts, setSavedContacts] = useState(() => { + try { + const data = localStorage.getItem('doormile_saved_contacts'); + return data ? JSON.parse(data) : []; + } catch { + return []; + } + }); + + const [saveForLater, setSaveForLater] = useState(true); + const [isSavedNow, setIsSavedNow] = useState(false); + // Map pin picker modal target ('pickup' | 'drop' | null) const [mapPickerTarget, setMapPickerTarget] = useState(null); @@ -184,6 +200,11 @@ export default function CreateOrder() { [staffTenants] ); + const selectedTenant = useMemo( + () => (activeTenants || []).find((t) => String(t.tenantid) === String(selectedTenantId)), + [activeTenants, selectedTenantId] + ); + const { data: tenantLocations = [], isLoading: isLocationsLoading } = useQuery({ queryKey: ['tenantLocations', selectedTenantId], queryFn: () => getTenantLocations(selectedTenantId), @@ -211,21 +232,105 @@ export default function CreateOrder() { queryFn: getAdminCustomers }); + // Combined customers: Merge server customers with locally saved/recent customer contacts + const allCustomers = useMemo(() => { + const list = [...(customerList || [])]; + const seenPhones = new Set(); + const result = []; + + // Prioritize saved contacts that have complete address info + for (const sc of savedContacts) { + const p = String(sc.phone || sc.contactno || '').replace(/\D/g, '').slice(-10); + if (p) { + seenPhones.add(p); + result.push(sc); + } + } + + for (const c of list) { + const p = String(c.phone || c.contactno || '').replace(/\D/g, '').slice(-10); + if (p && seenPhones.has(p)) { + // If server record exists but has no address, the local saved record is already retained + continue; + } + if (p) seenPhones.add(p); + result.push(c); + } + return result; + }, [customerList, savedContacts]); + const matchedCustomer = useMemo(() => { const cleanPhone = String(dropCust.contactno || '').replace(/\D/g, ''); if (cleanPhone.length >= 8) { - return (customerList || []).find((c) => String(c.phone || '').replace(/\D/g, '').endsWith(cleanPhone)); + return allCustomers.find((c) => String(c.phone || c.contactno || '').replace(/\D/g, '').endsWith(cleanPhone)); } return null; - }, [customerList, dropCust.contactno]); + }, [allCustomers, dropCust.contactno]); const filteredCustomers = useMemo(() => { const q = searchCustQuery.trim().toLowerCase(); - if (!q) return customerList; - return (customerList || []).filter( - (c) => c.name?.toLowerCase().includes(q) || c.phone?.includes(searchCustQuery) - ); - }, [customerList, searchCustQuery]); + if (!q) return allCustomers; + return allCustomers.filter((c) => { + const name = (c.name || c.firstname || '').toLowerCase(); + const phone = String(c.phone || c.contactno || ''); + const addr = (c.address || c.deliveryaddress || c.city || '').toLowerCase(); + return name.includes(q) || phone.includes(searchCustQuery) || addr.includes(q); + }); + }, [allCustomers, searchCustQuery]); + + // Persist a customer contact for future orders + const saveCustomerRecord = (cust = dropCust) => { + const digits = (v) => String(v ?? '').replace(/\D/g, ''); + 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); + return false; + } + + const newContact = { + id: `saved_${cleanNum}`, + appcustomerid: `saved_${cleanNum}`, + name, + firstname: name, + phone: cleanNum, + contactno: cleanNum, + address: cust.address || '', + suburb: cust.suburb || '', + city: cust.city || '', + postcode: cust.postcode || '', + landmark: cust.landmark || '', + latitude: cust.latitude ? Number(cust.latitude) : undefined, + longitude: cust.longitude ? Number(cust.longitude) : undefined, + isSavedForLater: true, + updatedAt: new Date().toISOString() + }; + + setSavedContacts((prev) => { + 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))); + } catch {} + return next; + }); + + // Also persist to backend asynchronously if endpoint is reachable + createAdminCustomer({ + name, + firstname: name, + phone: cleanNum, + address: cust.address || '', + city: cust.city || '', + postcode: cust.postcode || '', + landmark: cust.landmark || '', + latitude: cust.latitude ? Number(cust.latitude) : 0, + longitude: cust.longitude ? Number(cust.longitude) : 0 + }).catch(() => {}); + + setIsSavedNow(true); + OpenToast(`Saved "${name}" (${cleanNum}) for future orders`, 'success', 3000); + return true; + }; const sortedLocations = useMemo(() => sortLocations(tenantLocations), [tenantLocations]); @@ -252,13 +357,32 @@ export default function CreateOrder() { const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat; const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong; + // Resolve matching hub name and phone + const matchingHub = (hubList || []).find( + (h) => + (loc.city && h.city && h.city.toLowerCase() === loc.city.toLowerCase()) || + (loc.hubid && h.hubid === loc.hubid) || + (h.hubname && loc.locationname && h.hubname.toLowerCase().includes(loc.locationname.toLowerCase())) + ) || hubList[0]; + + const hubName = loc.locationname || matchingHub?.hubname || selectedTenant?.tenantname || 'Coimbatore Hub'; + const contactPhone = String( + loc.contactno || + loc.phone || + selectedTenant?.primarycontact || + selectedTenant?.contactno || + selectedTenant?.phone || + matchingHub?.contactno || + '' + ) + .replace(/\D/g, '') + .slice(0, 10); + // @ts-ignore setPickCust((prev) => ({ ...prev, - firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point', - contactno: String(loc.contactno || loc.phone || prev.contactno || '') - .replace(/\D/g, '') - .slice(0, 10), + firstname: loc.contactname || loc.contactperson || hubName, + contactno: contactPhone || prev.contactno, address: loc.address || '', landmark: loc.landmark || '', suburb: loc.locationname || loc.city || '', @@ -478,55 +602,74 @@ export default function CreateOrder() { // Select customer from modal const handlePickCustomer = (customer) => { - // GET /admin/customers already returns the whole address — doorno, address, - // suburb, city, state, postcode, landmark, latitude, longitude (see - // GetAdminCustomers in adminController.go). This used to take the name and - // the phone and drop the rest on the floor, so picking a saved contact - // filled two fields and left the operator to retype an address the system - // already had. - // - // The COORDINATES matter more than the text. handleSubmit validates drop - // coordinates, and the Order Summary's distance and total are computed - // from them — filling the address without them produces a form that looks - // complete, prices at the base rate, and then fails on submit. That is - // worse than filling nothing. + if (!customer) return; const digits = (v) => String(v ?? '').replace(/\D/g, ''); const text = (v) => String(v ?? '').trim(); - // Door number and landmark are separate columns but there is one address - // line on this form, so fold them in rather than lose them. - const street = [text(customer.doorno), text(customer.address)].filter(Boolean).join(', '); - const lat = customer.latitude; - const lng = customer.longitude; - const hasCoords = Number.isFinite(Number(lat)) && Number.isFinite(Number(lng)) && Number(lat) !== 0 && Number(lng) !== 0; + // Support multiple field schemas (Doormile appcustomers, legacy Nearle customers, or saved orders) + const rawAddress = text( + customer.deliveryaddress || + customer.address || + customer.formatted_address || + customer.location || + '' + ); + const street = [text(customer.doorno), rawAddress].filter(Boolean).join(', ') || rawAddress; + + const lat = customer.latitude ?? customer.deliverylatitude ?? customer.lat; + const lng = customer.longitude ?? customer.deliverylongitude ?? customer.lng ?? customer.long; + const hasCoords = + lat != null && + lng != null && + Number.isFinite(Number(lat)) && + Number.isFinite(Number(lng)) && + Number(lat) !== 0 && + Number(lng) !== 0; + + const suburb = text(customer.suburb || customer.deliverysuburb || customer.area); + const city = text(customer.city || customer.deliverycity); + const postcode = text(customer.postcode || customer.pincode || customer.deliverypincode); + const landmark = text(customer.landmark || customer.deliverylandmark); setDropCust((prev) => ({ ...prev, - firstname: customer.name || prev.firstname, - contactno: digits(customer.phone) ? digits(customer.phone).slice(0, 10) : prev.contactno, - // Every field falls back to what is already typed. A contact with a name - // and no address must never blank an address the operator has entered — - // silently erasing their work is a worse bug than not filling it. + firstname: customer.name || customer.firstname || customer.customer_name || prev.firstname, + contactno: digits(customer.phone || customer.contactno || customer.customer_phone) + ? digits(customer.phone || customer.contactno || customer.customer_phone).slice(0, 10) + : prev.contactno, address: street || prev.address, - landmark: text(customer.landmark) || prev.landmark, - suburb: text(customer.suburb) || prev.suburb, - city: text(customer.city) || prev.city, - postcode: text(customer.postcode) || prev.postcode, - latitude: hasCoords ? lat : prev.latitude, - longitude: hasCoords ? lng : prev.longitude + landmark: landmark || prev.landmark, + suburb: suburb || prev.suburb, + city: city || prev.city, + postcode: postcode || prev.postcode, + latitude: hasCoords ? Number(lat) : prev.latitude, + longitude: hasCoords ? Number(lng) : prev.longitude })); setIsCustomerModalOpen(false); - // Say what actually happened. A contact with no address on file leaves the - // operator with a half-filled form, and a toast that says "Selected" either - // way lets them assume the address came across and submit without it. - const name = customer.name || customer.phone || 'Contact'; + const name = customer.name || customer.firstname || customer.phone || 'Contact'; if (street && hasCoords) { OpenToast(`${name} — contact and address filled`, 'success', 2500); } else if (street) { - OpenToast(`${name} — address filled, but no saved location. Pin it on the map for distance and pricing.`, 'warning', 4500); + OpenToast(`${name} — address filled. Resolving map coordinates…`, 'info', 3000); + // Automatically attempt to geocode the address to resolve lat/lng if not present + geocodeAddress(street) + .then((place) => { + if (place?.latitude && place?.longitude) { + setDropCust((curr) => ({ + ...curr, + latitude: Number(place.latitude), + longitude: Number(place.longitude), + city: curr.city || place.city || '', + suburb: curr.suburb || place.suburb || '', + postcode: curr.postcode || place.postcode || '' + })); + OpenToast('Location pinned automatically from address', 'success', 2500); + } + }) + .catch(() => {}); } else { - OpenToast(`${name} — no address on file. Enter the delivery address.`, 'warning', 4000); + OpenToast(`${name} — contact details selected. Please enter delivery address.`, 'warning', 3500); } }; @@ -550,11 +693,7 @@ export default function CreateOrder() { OpenToast('Enter pickup address', 'warning', 2500); return; } - // Pickup coordinates were never checked here. That was survivable only - // while the pickup was always a saved location, which supplies its own - // coordinates — a hand-typed collection address that fails to geocode - // submits 0,0, and an order pinned at 0,0 is unroutable, unpriceable and - // invisible to every zone filter. + // Pickup coordinates check if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) { OpenToast( isCustomerPickup @@ -615,20 +754,11 @@ export default function CreateOrder() { finalprice, notes: notes || '', parcels, - // Which flow this is, and which hub owns it. Sent even where the backend - // may not store them yet: an unknown key is ignored server-side, and the - // console classifies from coordinates in the meantime, so nothing depends - // on these landing. The day they are persisted, every order created from - // here carries its own type instead of being re-derived on each load. ...buildFlowFields({ flow: draftFlow.flow, servicingHubId: draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null, tenantLocationId: selectedLocationId, - // The collection point itself, so the order records what KIND of place - // it leaves from — a base, a client's site, or a customer's door. The - // backend stores it and the rider app titles the stop with it; without - // it every logistics pickup reads as the rider's own base name. pickupAnchor: draftFlow.originAnchor }) }; @@ -637,6 +767,10 @@ export default function CreateOrder() { try { const res = await createExpressBooking(payload); if (res?.success) { + // Save customer details for future orders if selected + if (saveForLater && dropCust.contactno && dropCust.firstname) { + saveCustomerRecord(dropCust); + } saveRecentAddress({ formatted_address: dropCust.address, name: dropCust.firstname, @@ -648,6 +782,7 @@ export default function CreateOrder() { }); queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] }); queryClient.invalidateQueries({ queryKey: ['ordersBulk'] }); + queryClient.invalidateQueries({ queryKey: ['adminCustomers'] }); OpenToast('Order created successfully', 'success', 2500); navigate('/doormile/orders'); } else { @@ -1086,11 +1221,66 @@ export default function CreateOrder() { type="text" placeholder="Near landmark" value={dropCust.landmark} - onChange={(e) => setDropCust({ ...dropCust, landmark: e.target.value })} + onChange={(e) => { + setDropCust({ ...dropCust, landmark: e.target.value }); + setIsSavedNow(false); + }} className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors" />
+ + {/* Save contact for future orders option */} + {dropCust.contactno && dropCust.contactno.length >= 8 && dropCust.firstname.trim() && ( +
+ + +
+ )} {/* Schedule & Packages Card */} @@ -1571,26 +1761,54 @@ export default function CreateOrder() { No customers found matching "{searchCustQuery}" ) : ( - filteredCustomers.map((cust) => ( -
-
-
{cust.name || 'Unnamed Contact'}
-
{cust.phone || '—'}
- {cust.email &&
{cust.email}
} -
- - -
- )) +
+
+ + {cust.name || cust.firstname || 'Unnamed Contact'} + + + ({cust.phone || cust.contactno || '—'}) + + {cust.isSavedForLater && ( + + Saved + + )} + {hasPin && ( + + 📍 Pinned + + )} +
+ {addr ? ( +
+ {addr} + {cust.city && · {cust.city}} + {cust.postcode && - {cust.postcode}} +
+ ) : ( +
No address on file
+ )} +
+ + + + ); + }) )}