upates on the toast and createorder and allthose things

This commit is contained in:
2026-09-25 16:02:57 +05:30
parent 3e7aaf1e25
commit f86e4b853b
16 changed files with 1274 additions and 133 deletions

View File

@@ -1,6 +1,6 @@
import { Suspense, lazy } from 'react';
import { Toaster } from '@/components/ui/toaster';
import { Toaster as HotToaster } from 'react-hot-toast';
import AppToaster from '@/components/third-party/AppToaster';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClientInstance } from '@/lib/query-client';
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
@@ -177,10 +177,11 @@ function App() {
<AuthenticatedApp />
</Router>
<Toaster />
{/* The design system's `toast` helper wraps react-hot-toast, which needs
its own renderer mounted. Without this every toast in the app fires
into nothing — including the data layer's failure notices. */}
<HotToaster position="bottom-right" />
{/* Every toast in the app (OpenToast, the design system's `toast`,
direct react-hot-toast calls) renders here: top-right, one card
design. Without a mounted renderer every toast fires into
nothing — including the data layer's failure notices. */}
<AppToaster />
</ZoneProvider>
</QueryClientProvider>
</AuthProvider>

View File

@@ -38,7 +38,10 @@ export const DOORMILE_SESSION_KEYS = ['authname', 'firstname', 'userid', 'roleid
* terminal — leaving the transcript behind hands the next operator the last
* one's work. The panel width is a preference and stays.
*/
export const DOORMILE_ASSISTANT_KEYS = ['doormileBotHistory', 'doormileBotConversations'];
/** The home page's MileTruth chat history (Home.jsx). Same reasoning as above. */
export const MILETRUTH_HOME_CHATS_KEY = 'miletruthHomeChats';
export const DOORMILE_ASSISTANT_KEYS = ['doormileBotHistory', 'doormileBotConversations', MILETRUTH_HOME_CHATS_KEY];
/** Clears every key that makes up a Doormile session. */
export const clearStoredSession = () => {

View File

@@ -27,8 +27,10 @@ const BASE_STYLE = {
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
// No per-toast position: every toast renders in AppToaster's top-right stack.
// This used to force 'bottom-center', so design-system toasts and OpenToast
// ones appeared in two different corners of the same screen.
const withDefaults = (options = {}) => ({
position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
...options,
style: { ...BASE_STYLE, ...(options.style || {}) },
});
@@ -51,7 +53,7 @@ export const toast = {
},
info(message, options) {
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
return hotToast(message, { duration: DURATIONS.info, className: 'dm-toast-info', ...withDefaults(options) });
},
loading(message, options) {

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { Toaster, toast as hotToast } from 'react-hot-toast';
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from 'lucide-react';
/**
* The one toast renderer for the console.
*
* Every toast — OpenToast (most of the app), the design system's `toast`
* helper, and direct react-hot-toast calls — renders here, top-right, as the
* same card: coloured accent, icon, a short title, the message, and a close
* button. Before this they appeared bottom-right or bottom-centre depending on
* which helper fired them, as bare white pills with no way to dismiss.
*
* react-hot-toast has no "warning" or "info" type, so OpenToast tags those
* with a className (`dm-toast-warning` / `dm-toast-info`) and this reads it.
*/
const VARIANTS = {
success: {
title: 'Done',
Icon: CheckCircle2,
accent: 'bg-emerald-500',
iconWrap: 'bg-emerald-50 text-emerald-600'
},
error: {
title: 'Something went wrong',
Icon: XCircle,
accent: 'bg-rose-500',
iconWrap: 'bg-rose-50 text-rose-600'
},
warning: {
title: 'Please check',
Icon: AlertTriangle,
accent: 'bg-amber-500',
iconWrap: 'bg-amber-50 text-amber-600'
},
info: {
title: 'Heads up',
Icon: Info,
accent: 'bg-sky-500',
iconWrap: 'bg-sky-50 text-sky-600'
},
loading: {
title: 'Working on it',
Icon: Loader2,
accent: 'bg-slate-400',
iconWrap: 'bg-slate-100 text-slate-500',
spin: true
}
};
export const variantOf = (t) => {
if (t.type === 'success') return 'success';
if (t.type === 'error') return 'error';
if (t.type === 'loading') return 'loading';
if (String(t.className || '').includes('dm-toast-warning')) return 'warning';
return 'info';
};
function ToastCard({ t }) {
const variant = VARIANTS[variantOf(t)];
const { Icon } = variant;
const urgent = t.type === 'error' || variantOf(t) === 'warning';
const message = typeof t.message === 'function' ? t.message(t) : t.message;
return (
<div
{...t.ariaProps}
role={urgent ? 'alert' : 'status'}
aria-live={urgent ? 'assertive' : 'polite'}
style={{
// Width is inline, not a Tailwind class: react-hot-toast measures each
// card once to stack them, and a card measured before its width class
// applied wrapped one word per line and pushed the next toast far off
// screen.
width: 360,
maxWidth: 'calc(100vw - 2rem)',
opacity: t.visible ? 1 : 0,
transform: t.visible ? 'translateX(0)' : 'translateX(16px)',
transition: 'opacity 180ms ease, transform 180ms ease'
}}
className="pointer-events-auto relative flex items-start gap-3 overflow-hidden rounded-xl border border-slate-200/90 bg-white py-3 pl-4 pr-3 shadow-[0_12px_32px_-8px_rgb(16_24_40/0.18),0_4px_10px_-4px_rgb(16_24_40/0.08)]"
>
<span className={`absolute inset-y-0 left-0 w-1 ${variant.accent}`} aria-hidden="true" />
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${variant.iconWrap}`} aria-hidden="true">
<Icon className={`h-4 w-4 ${variant.spin ? 'animate-spin' : ''}`} />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-[13px] font-semibold leading-5 text-slate-900">{variant.title}</p>
<div className="mt-0.5 text-[12.5px] leading-[1.45] text-slate-600 break-words">{message}</div>
</div>
{t.type !== 'loading' && (
<button
type="button"
onClick={() => hotToast.dismiss(t.id)}
className="-mr-1 mt-0.5 rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
aria-label="Dismiss notification"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export default function AppToaster() {
return (
<Toaster
position="top-right"
gutter={10}
// Below the 56px header bar, so a toast never covers search or the
// account menu.
containerStyle={{ top: 68, right: 16 }}
toastOptions={{ duration: 3500 }}
>
{(t) => <ToastCard t={t} />}
</Toaster>
);
}

View File

@@ -1,5 +1,8 @@
import toast from 'react-hot-toast';
// Rendered by AppToaster (top-right card). react-hot-toast has no warning or
// info type, so those are tagged with a className the renderer reads — before,
// a warning and an info looked identical.
export const OpenToast = (message, color, time) => {
const duration = time || 2500;
if (color === 'error') {
@@ -8,5 +11,8 @@ export const OpenToast = (message, color, time) => {
if (color === 'success') {
return toast.success(message, { duration });
}
return toast(message, { duration });
if (color === 'warning') {
return toast(message, { duration, className: 'dm-toast-warning' });
}
return toast(message, { duration, className: 'dm-toast-info' });
};

View File

@@ -30,7 +30,8 @@ import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
* Core destinations sit on the bar; the rest live in two grouped menus on it
* (Fleet Ops, Reports) plus a Settings section inside the account menu, which
* holds the two destinations that are configuration rather than operations
* (Pricing Matrix, Customers Directory).
* (Pricing Matrix, Customers Directory). Customers is also on the bar itself,
* beside Clients — Settings was its only entry point and nobody found it.
*
* Said precisely because the previous wording — "three grouped menus (Fleet
* Ops, Reports, Settings)" — sent a reader looking for a third NAV_GROUPS
@@ -53,6 +54,10 @@ const NAV = [
{ label: 'Deliveries', path: '/doormile/deliveries' },
{ label: 'Milers', path: '/doormile/milers' },
{ label: 'Clients', path: '/doormile/tenants' },
// Beside Clients: a client, then that client's end customers. Also kept as
// a tab under Settings, but that was the only way in, and it's a daily
// destination (look up a recipient, add one), not configuration.
{ label: 'Customers', path: '/doormile/customers' },
];
/** Destinations grouped behind a menu, in the order the console lists them. */

View File

@@ -189,6 +189,9 @@ export const executeCreateCustomer = async (payload) => {
return {
ok: false,
status,
// The server's own reason, unformatted, for callers that word the
// message themselves (the home page's plain-language toasts).
serverMessage,
message,
sourceCalls: [
{

View File

@@ -0,0 +1,168 @@
// ==============================|| MileTruth — greetings & "what can you do?" ||============================== //
//
// "good afternoon" used to reach the intent router, match nothing, and get
// "I could not answer that from live data" — the first thing a new user typed
// was answered with a failure. This module answers it instead: a greeting back,
// then what the assistant can actually do, with examples that run when clicked.
//
// Deliberately narrow. A greeting only counts when it is the WHOLE message, or
// the opening of one ("hi, how many orders today?") — then the greeting is
// stripped and the question is answered normally. Nothing here touches data.
//
// Keep CAPABILITIES honest: list only what the home page really does. Every
// example is sent as a real question when clicked, so a wrong one fails in
// front of the user.
const GREETING_RE =
/^\s*(?:(good\s*(?:morning|afternoon|evening|night|day))|(hi+|hello+|hey+|hiya|howdy|yo|namaste|vanakkam|greetings|gm|ge|ga))\b[\s,!.]*(?:(?:there|team|all|everyone|miletruth|mile\s*truth|ai|bot|assistant|buddy|friend|sir|madam)\b[\s,!.]*)?/i;
// ---- "What can you do?" -------------------------------------------------------
//
// Recognised by MEANING, not by an exact sentence. The first version listed
// fixed phrasings, and "what are things you can do ?" — an ordinary way to
// ask — wasn't one of them, so it got "Sorry, I didn't understand". People
// word this question a hundred ways; the rules below look for its shape:
// a question about YOU (the assistant) and what it does / offers / knows.
//
// Two guards keep real questions out:
// • DOMAIN words (orders, riders, hubs, revenue …) mean it's an operations
// question — "what can you tell me about orders today" goes to the router.
// • Long messages (> 14 words) are never treated as small talk.
const DOMAIN_RE =
/\b(?:orders?|bookings?|deliver(?:y|ies)|riders?|milers?|drivers?|hubs?|vehicles?|fleet|revenue|sales|profit|pricing|price|consignments?|parcels?|exceptions?|tripsheets?|customers?|clients?|tenants?|dispatch|batch|pincode|today|yesterday|week|month|dm-[a-z0-9-]+|\d{4,})\b/i;
// Exact short commands that mean "show me the menu".
const MENU_RE =
/^(?:help|help\s+me|menu|options|features|capabilities|commands|guide|start|get\s+started|how\s+are\s+you(?:\s+doing)?|what'?s\s+up|sup|what\s+now|now\s+what|anything\s+else|what\s+else)$/;
const CAPABILITY_PATTERNS = [
// what (are the / all / kind of) things can you do · what do you do · what else can u help with
/\b(?:what|wat|wht|which)\b.*\b(?:you|u|ya)\b.*\b(?:do|does|help|offer|handle|support|manage|provide|know|able)\b/,
// what can I (ask / do / use) · what should I ask · what questions can I ask
/\b(?:what|wat|which|how)\b.*\b(?:can|could|should|do)\b.*\b(?:i|we)\b.*\b(?:ask|do|use|say|type|try)\b/,
// can you help me · can you do anything · are you able to help
/^(?:can|could|will|would|are)\s+(?:you|u)\b.*\b(?:help|do|assist|able)\b/,
// your features / abilities / skills / functions / commands
/\b(?:your|ur)\b.*\b(?:features?|capabilit(?:y|ies)|abilit(?:y|ies)|skills?|functions?|commands?|uses?|job|purpose|role)\b/,
// list / show / tell me (all) your features · show me what you can do
/\b(?:list|show|tell|explain|give)\b.*\b(?:features?|capabilit(?:y|ies)|abilit(?:y|ies)|skills?|functions?|commands?|what\s+(?:you|u)\s+(?:can|do))\b/,
// who / what are you · what is this (ai / bot / tool / thing / page) · what is miletruth
/\bwho\b.*\b(?:are|r)\b.*\b(?:you|u)\b/,
/\bwhat\b.*\b(?:is|are|'s)\b.*\b(?:you|u|this|miletruth|mile\s*truth)\b/,
// how does this work · how do I use this / you · how to use · how to start
/\bhow\b.*\b(?:does|do|to|can)\b.*\b(?:work|use|start|begin|get\s+started)\b/,
// why should I use you · what are you for · what are you good at
/\b(?:good\s+at|for\s+what|what\s+for|used\s+for)\b/
];
const normalize = (text) =>
String(text || '')
.toLowerCase()
.replace(/[’`]/g, "'")
.replace(/[^a-z0-9' -]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
export const isCapabilitiesQuestion = (text) => {
const t = normalize(text);
if (!t) return false;
if (MENU_RE.test(t)) return true;
if (t.split(' ').length > 14) return false;
if (DOMAIN_RE.test(t)) return false;
return CAPABILITY_PATTERNS.some((re) => re.test(t));
};
// Short courtesies with no question in them — answered with a short reply, not
// the full capability list.
const THANKS_RE = /^\s*(?:thanks?|thank\s+you|thx|ty|ok(?:ay)?|cool|great|nice|super|awesome|got\s+it)\b[\s!.]*(?:(?:so\s+much|a\s+lot|miletruth|mile\s*truth)\b[\s!.]*)?$/i;
const clockGreeting = (now) => {
const h = now.getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
};
/**
* Splits a leading greeting off a message.
* Returns { greeting, rest } — greeting is the phrase to answer with (null
* when there wasn't one), rest is whatever followed it, trimmed.
*/
export const splitGreeting = (text, now = new Date()) => {
const raw = String(text || '');
const m = raw.match(GREETING_RE);
if (!m) return { greeting: null, rest: raw.trim() };
// Mirror the greeting the user chose ("good afternoon" → "Good afternoon",
// "gm" → "Good morning"); answer a plain hi/hello by the clock.
const ABBREVIATIONS = { gm: 'morning', ga: 'afternoon', ge: 'evening' };
const part = m[1]
? m[1].replace(/^good\s*/i, '').toLowerCase()
: ABBREVIATIONS[String(m[2] || '').toLowerCase()];
const greeting = part ? `Good ${part}` : clockGreeting(now);
return { greeting, rest: raw.slice(m[0].length).trim() };
};
export const isThanks = (text) => THANKS_RE.test(String(text || ''));
/** What the home page does, grouped, each with examples that run on click. */
export const CAPABILITIES = [
{
title: 'Create & book',
description: 'I collect the details step by step, and nothing is saved until you press confirm.',
examples: ['Create a new order', 'Create a new customer', 'Assign a rider to an order', "Repeat yesterday's orders"]
},
{
title: 'Orders & deliveries',
description: 'Live counts, trends and status from the order board.',
examples: ['How many orders today?', 'Which orders are delayed?', 'Orders today vs yesterday']
},
{
title: 'Riders & fleet',
description: 'Who is working, which vehicles are free, and how each hub is doing.',
examples: ['How many riders are active?', 'How many vehicles are available?', 'Current hub status']
},
{
title: 'Money & reports',
description: 'Revenue, consignments and exceptions that need attention.',
examples: ['Total revenue today', 'How many consignments do we have?', 'Show open exceptions']
}
];
/**
* Answers a greeting / capability / thanks message, or returns null when the
* message is something else (the caller then routes it normally, using
* `rest` so the greeting doesn't confuse the router).
*
* Returns { kind, title, summary, capabilities?, rest }.
*/
export const answerSmallTalk = (text, { name = '', now = new Date() } = {}) => {
const { greeting, rest } = splitGreeting(text, now);
const who = String(name || '').trim().split(/\s+/)[0];
const hello = greeting ? `${greeting}${who ? `, ${who}` : ''}!` : '';
const intro =
'I’m MileTruth, your Doormile operations assistant. I read live data from the console to answer questions, and I can create orders and customers for you — always showing you exactly what will be saved before anything is sent.';
if (!rest || isCapabilitiesQuestion(rest)) {
return {
kind: greeting && !rest ? 'greeting' : 'capabilities',
title: hello ? `${hello} Here’s what I can do for you.` : 'Here’s what I can do for you.',
summary: `${intro} Click any example below to try it, or just type your question.`,
capabilities: CAPABILITIES,
rest: ''
};
}
if (isThanks(rest)) {
return {
kind: 'thanks',
title: 'You’re welcome! Anything else I can help with?',
summary: 'Ask about orders, riders, hubs or revenue — or say “what can you do?” to see everything.',
rest: ''
};
}
// A greeting in front of a real question: let the question through.
return greeting ? { kind: 'passthrough', greeting: hello, rest } : null;
};

View File

@@ -284,17 +284,23 @@ export default function AgentFlowVisualizer({
const updateCoordinates = () => {
if (!canvasRef.current) return;
const cRect = canvasRef.current.getBoundingClientRect();
// On large screens the page is scaled with CSS `zoom` (Agents.css).
// getBoundingClientRect reports scaled screen pixels, but the SVG these
// points feed is inside the zoomed element and scaled again — so without
// dividing back out, every connector line lands off its node. offsetWidth
// is unscaled, so the ratio is the zoom in effect (1 when there is none).
const scale = canvasRef.current.offsetWidth ? cRect.width / canvasRef.current.offsetWidth : 1;
const getMetrics = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
x: r.left - cRect.left + r.width / 2,
y: r.top - cRect.top + r.height / 2,
left: r.left - cRect.left,
right: r.right - cRect.left,
top: r.top - cRect.top,
bottom: r.bottom - cRect.top,
x: (r.left - cRect.left + r.width / 2) / scale,
y: (r.top - cRect.top + r.height / 2) / scale,
left: (r.left - cRect.left) / scale,
right: (r.right - cRect.left) / scale,
top: (r.top - cRect.top) / scale,
bottom: (r.bottom - cRect.top) / scale,
};
};

View File

@@ -0,0 +1,39 @@
/*
* Agents page — scale up on big monitors.
*
* Every part of this page is sized in fixed pixels (11px labels, fixed card
* widths, SVG connectors), so on a 1920px+ screen it sat in a narrow
* 1280px column with small text and wide empty margins. Widening the column
* alone would only stretch the gaps, so the whole page is scaled instead.
*
* Keyed on the space the page actually has (a container query on
* .agents-shell), not the screen width: with the MileTruth side panel open,
* a 1920px screen leaves ~1500px, and a screen-width rule would push the
* page wider than its column. Each step keeps 1280px × zoom inside the
* container. Laptops (< 1500px available) are unchanged.
*
* AgentFlowVisualizer divides its measured connector points by the zoom in
* effect, so the flow lines stay on their nodes.
*/
.agents-shell {
container-type: inline-size;
}
@container (min-width: 1500px) {
.agents-zoom { zoom: 1.1; }
}
@container (min-width: 1700px) {
.agents-zoom { zoom: 1.2; }
}
@container (min-width: 1850px) {
.agents-zoom { zoom: 1.28; }
}
@container (min-width: 2100px) {
.agents-zoom { zoom: 1.4; }
}
@container (min-width: 2500px) {
.agents-zoom { zoom: 1.6; }
}
@container (min-width: 3300px) {
.agents-zoom { zoom: 2; }
}

View File

@@ -31,6 +31,7 @@ import {
} from '@/lib/agentNetwork';
import AgentFlowVisualizer from './AgentFlowVisualizer';
import AgentInspectorDrawer from './AgentInspectorDrawer';
import './Agents.css';
export default function Agents() {
const [selectedAgentId, setSelectedAgentId] = useState('DISPATCH_AGENT');
@@ -81,7 +82,12 @@ export default function Agents() {
};
return (
<div className="flex flex-col gap-6 p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto w-full">
// Outer shell measures the space available (a CSS container); the inner
// wrapper is scaled up on big monitors (Agents.css). The drawer and the
// diagnostics modal stay OUTSIDE the scaled wrapper so they open at normal
// size and position.
<div className="agents-shell w-full">
<div className="agents-zoom flex flex-col gap-6 p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto w-full">
{/* ═══════════════════════════════════════════════════════════════════
COMPACT TOP STATUS BAR (REQUIREMENT: TOP STATUS BAR)
Clean, minimal telemetry ribbon: ● 1 Processing · ● 1 Warning · ● 7 Idle · 🔒 Autonomy locked
@@ -265,6 +271,8 @@ export default function Agents() {
</div>
</div>
</div>
{/* ═══════════════════════════════════════════════════════════════════
DETAIL INSPECTOR DRAWER (TECHNICAL DETAILS LIVE HERE ONLY)
═══════════════════════════════════════════════════════════════════ */}

View File

@@ -1,18 +1,30 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, MapPin } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, SectionHeader, Stack, Surface,
} from '@/components/ds';
import { useCreateCustomer } from '@/lib/doormileHooks';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
import { OpenToast } from '@/components/third-party/OpenToast';
import { createTenantCustomer } from '@/api/doormile/endpoints';
import { friendlyErrorMessage } from '@/api/doormile/client';
import { useTenantCustomers } from '@/lib/doormileHooks';
/**
* Create a customer.
*
* Writes to `/admin/customers` — the same store the Customers page reads — so a
* customer added here shows up there immediately rather than landing in the
* tenant-customers resource, which the console lists somewhere else entirely.
* Writes to `POST /admin/tenantcustomers` — the store the Customers page reads
* (`useTenantCustomers`) and the one Doormile AI's create-customer writes to,
* so page, list and assistant agree.
*
* It used to write to `POST /admin/customers`, which the live server answers
* with 405 Method Not Allowed: that resource is GET + PATCH only, and a B2C
* customer there only comes into existence through a booking. Every submit
* from this page failed. See lib/assistant/CLAUDE.md, "Customer creation
* writes to /admin/tenantcustomers" — don't point it back.
*/
const EMPTY_FORM = {
@@ -31,6 +43,8 @@ const EMPTY_FORM = {
longitude: '',
};
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
/** Drops blank keys — the API treats an empty string and an absent field
differently on several of these, and an empty one is never what was meant. */
const clean = (object) =>
@@ -38,34 +52,128 @@ const clean = (object) =>
Object.entries(object).filter(([, value]) => value !== undefined && value !== null && value !== '')
);
const digitsOf = (value) => String(value ?? '').replace(/\D/g, '').slice(-10);
/** Every check, worded as what to do rather than what is wrong. */
const validate = (form) => {
const errors = {};
if (form.firstname.trim().length < 2) errors.firstname = 'Enter the customer’s first name (at least 2 letters).';
if (!/^\d{10}$/.test(form.phone)) {
errors.phone = form.phone
? `Mobile number must be exactly 10 digits — you’ve entered ${form.phone.length}.`
: 'Enter the customer’s 10-digit mobile number.';
}
if (form.email && !EMAIL_RE.test(form.email.trim())) errors.email = 'This doesn’t look like an email address — check it, or leave it blank.';
if (form.postcode && !/^\d{6}$/.test(form.postcode)) errors.postcode = 'Pincode must be 6 digits.';
return errors;
};
/** Pulls suburb / city / state / pincode / coordinates out of a picked place,
whichever shape the geocoder returned it in. Same reading as CreateOrder. */
const fieldsFromPlace = (place) => {
const parsed = {};
(place.address_components || []).forEach((c) => {
(c.types || []).forEach((type) => {
if (type === 'sublocality_level_1' || type === 'sublocality') parsed.suburb = c.long_name;
if (type === 'locality') parsed.city = c.long_name;
if (type === 'administrative_area_level_1') parsed.state = c.long_name;
if (type === 'postal_code') parsed.postcode = c.long_name;
});
});
const lat = place.latitude ?? (place.geometry?.location?.lat ? place.geometry.location.lat() : '');
const lng = place.longitude ?? (place.geometry?.location?.lng ? place.geometry.location.lng() : '');
return {
address: place.formatted_address || place.name || '',
suburb: place.suburb || parsed.suburb || '',
city: place.city || parsed.city || '',
state: place.state || parsed.state || '',
postcode: String(place.postcode || parsed.postcode || '').replace(/\D/g, '').slice(0, 6),
latitude: lat === '' || lat == null ? '' : String(lat),
longitude: lng === '' || lng == null ? '' : String(lng),
};
};
export default function CreateCustomer() {
const navigate = useNavigate();
const createCustomer = useCreateCustomer();
const queryClient = useQueryClient();
const { data: existing = [] } = useTenantCustomers();
const [form, setForm] = useState(EMPTY_FORM);
const [errors, setErrors] = useState({});
const [isMapOpen, setIsMapOpen] = useState(false);
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
const set = (key) => (value) => {
setForm((current) => ({ ...current, [key]: value }));
// Clear a field's message as soon as it is edited, not only on the next submit.
if (errors[key]) setErrors((current) => ({ ...current, [key]: undefined }));
};
const applyPlace = (place) => {
if (!place) return;
const next = fieldsFromPlace(place);
setForm((current) => ({
...current,
...Object.fromEntries(Object.entries(next).filter(([, v]) => v !== '')),
}));
};
// Warn — don't block — when the number is already on file. The same person
// can legitimately be a customer of two clients.
const duplicate = useMemo(() => {
if (form.phone.length !== 10) return null;
return (existing || []).find((row) => digitsOf(row.phone || row.contactno) === form.phone) || null;
}, [existing, form.phone]);
const duplicateName = duplicate
? duplicate.name || [duplicate.firstname, duplicate.lastname].filter(Boolean).join(' ') || 'another customer'
: '';
const create = useMutation({
mutationFn: createTenantCustomer,
onSuccess: (res) => {
if (res && res.success === false) {
OpenToast(
res.message ? `We couldn't create this customer: ${res.message}` : "We couldn't create this customer. Please check the details and try again.",
'error',
7000
);
return;
}
queryClient.invalidateQueries({ queryKey: ['doormile', 'tenant-customers'] });
const name = [form.firstname.trim(), form.lastname.trim()].filter(Boolean).join(' ');
OpenToast(`${name} has been added to your customers.`, 'success', 3500);
navigate('/doormile/customers');
},
onError: (err) => OpenToast(friendlyErrorMessage(err, 'create this customer'), 'error', 7000),
});
const submit = (event) => {
event.preventDefault();
const nextErrors = {};
if (!form.firstname.trim()) nextErrors.firstname = 'Enter a name';
if (!/^\d{10}$/.test(form.phone)) nextErrors.phone = 'Enter a 10-digit mobile number';
const nextErrors = validate(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
const problems = Object.values(nextErrors).filter(Boolean);
if (problems.length) {
OpenToast(
problems.length === 1 ? problems[0] : `Please fix ${problems.length} fields marked in red before saving.`,
'warning',
5000
);
return;
}
createCustomer.mutate(
create.mutate(
clean({
...form,
firstname: form.firstname.trim(),
lastname: form.lastname.trim(),
email: form.email.trim(),
latitude: form.latitude ? Number(form.latitude) : undefined,
longitude: form.longitude ? Number(form.longitude) : undefined,
}),
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/customers') }
})
);
};
const hasPin = Boolean(Number(form.latitude) && Number(form.longitude));
return (
<Stack space="lg">
<PageHeader
@@ -89,10 +197,14 @@ export default function CreateCustomer() {
}
/>
<form onSubmit={submit}>
<form onSubmit={submit} noValidate>
<Stack space="default">
<Surface radius="xl" padding="lg">
<SectionHeader title="Who" className="mb-4" />
<SectionHeader
title="Who"
subtitle="Name and mobile number are required. Everything else is optional."
className="mb-4"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="First name" required error={errors.firstname}>
<Input value={form.firstname} onChange={(e) => set('firstname')(e.target.value)} placeholder="Anitha" />
@@ -103,12 +215,12 @@ export default function CreateCustomer() {
<Field label="Mobile number" required error={errors.phone}>
<Input
value={form.phone}
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').replace(/^(?:91|0)(?=\d{10})/, '').slice(0, 10))}
placeholder="9876543210"
inputMode="numeric"
/>
</Field>
<Field label="Email">
<Field label="Email" error={errors.email}>
<Input
type="email"
value={form.email}
@@ -117,25 +229,56 @@ export default function CreateCustomer() {
/>
</Field>
</div>
{duplicate && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<strong>{duplicateName}</strong> already uses {form.phone}. You can still save — for example if they are
a customer of another client — or{' '}
<Link to="/doormile/customers" className="font-semibold underline">
open the Customers page
</Link>{' '}
to edit the existing record instead.
</div>
)}
</Surface>
<Surface radius="xl" padding="lg">
<SectionHeader
title="Where"
subtitle="Coordinates are optional — a rider can still be routed on the address alone."
subtitle="Search for the address to fill the city, pincode and map location automatically, or drop a pin on the map."
className="mb-4"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-3">
<AddressAutocomplete
id="customer-address"
label="Address"
placeholder="Search area, street, society, pincode or paste a map link…"
value={form.address}
onChange={(text) => set('address')(text)}
onPlaceSelected={applyPlace}
onOpenMapPin={() => setIsMapOpen(true)}
onLandmarkExtracted={(lmk) => setForm((current) => (current.landmark ? current : { ...current, landmark: lmk }))}
/>
<div className="mt-2 flex items-center gap-1.5 text-xs">
<MapPin className={hasPin ? 'h-3.5 w-3.5 text-emerald-600' : 'h-3.5 w-3.5 text-slate-400'} />
{hasPin ? (
<span className="text-emerald-700">
Location pinned ({Number(form.latitude).toFixed(5)}, {Number(form.longitude).toFixed(5)}).
</span>
) : (
<span className="text-slate-500">
No map location yet — optional, but it makes this customer faster to book.
</span>
)}
<button type="button" className="ml-1 font-semibold text-brand hover:underline" onClick={() => setIsMapOpen(true)}>
{hasPin ? 'Adjust pin' : 'Pin on map'}
</button>
</div>
</div>
<Field label="Door number">
<Input value={form.doorno} onChange={(e) => set('doorno')(e.target.value)} placeholder="12/4" />
</Field>
<Field label="Address" className="lg:col-span-2">
<Input
value={form.address}
onChange={(e) => set('address')(e.target.value)}
placeholder="Street, area"
/>
</Field>
<Field label="Landmark">
<Input value={form.landmark} onChange={(e) => set('landmark')(e.target.value)} placeholder="Near the temple" />
</Field>
@@ -148,30 +291,40 @@ export default function CreateCustomer() {
<Field label="State">
<Input value={form.state} onChange={(e) => set('state')(e.target.value)} placeholder="Tamil Nadu" />
</Field>
<Field label="Pincode">
<Input value={form.postcode} onChange={(e) => set('postcode')(e.target.value)} placeholder="641004" />
<Field label="Pincode" error={errors.postcode}>
<Input
value={form.postcode}
onChange={(e) => set('postcode')(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="641004"
inputMode="numeric"
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Latitude">
<Input value={form.latitude} onChange={(e) => set('latitude')(e.target.value)} placeholder="11.0168" />
</Field>
<Field label="Longitude">
<Input value={form.longitude} onChange={(e) => set('longitude')(e.target.value)} placeholder="76.9558" />
</Field>
</div>
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/customers')}>
Cancel
</Button>
<Button type="submit" loading={createCustomer.isPending}>
<Button type="submit" loading={create.isPending}>
Create customer
</Button>
</div>
</Surface>
</Stack>
</form>
<MapPinPickerModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
title="Pin the customer's location"
initialAddress={form.address}
initialLat={form.latitude}
initialLng={form.longitude}
onConfirm={(place) => {
applyPlace(place);
setIsMapOpen(false);
}}
/>
</Stack>
);
}

View File

@@ -6,6 +6,7 @@ import {
Modal, Stack,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import { OpenToast } from '@/components/third-party/OpenToast';
import { useTenantCustomers, useUpdateTenantCustomer } from '@/lib/doormileHooks';
import { matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
@@ -114,42 +115,64 @@ export default function Customers({ embedded = false }) {
const save = () => {
const id = custId(editRow);
// If it's a locally saved contact, update localStorage directly
const name = String(form.name || '').trim();
const cleanPhone = String(form.phone || '').replace(/\D/g, '').replace(/^(?:91|0)(?=\d{10}$)/, '');
if (name.length < 2) {
OpenToast('Enter the customer’s name (at least 2 letters).', 'warning', 4000);
return;
}
if (!/^\d{10}$/.test(cleanPhone)) {
OpenToast('Mobile number must be exactly 10 digits, without +91 or a leading 0.', 'warning', 4000);
return;
}
// A contact saved from Create Order lives only in this browser, so it is
// edited in localStorage rather than through the API.
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;
});
const isThis = (item) => item.id === id || item.appcustomerid === id;
// Match on the id ONLY. Matching on the new phone number as well used
// to overwrite a second, unrelated contact whenever the number was
// changed to one already on file.
const clash = stored.find(
(item) => !isThis(item) && String(item.phone || item.contactno || '').replace(/\D/g, '').slice(-10) === cleanPhone
);
if (clash) {
OpenToast(
`${clash.name || clash.firstname || 'Another saved contact'} already uses ${cleanPhone}. Use a different number, or edit that contact instead.`,
'warning',
6000
);
return;
}
const updated = stored.map((item) =>
isThis(item)
? { ...item, name, firstname: name, phone: cleanPhone, contactno: cleanPhone, email: String(form.email || '').trim(), updatedAt: new Date().toISOString() }
: item
);
localStorage.setItem('doormile_saved_contacts', JSON.stringify(updated));
window.dispatchEvent(new Event('doormile_saved_contacts_updated'));
} catch {}
setEditRow(null);
OpenToast(`${name}'s details have been updated.`, 'success', 3000);
setEditRow(null);
} catch {
OpenToast("We couldn't save this change in your browser. Please try again, or check that site storage isn't blocked.", 'error', 6000);
}
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+/);
const [firstname, ...rest] = name.split(/\s+/);
updateCustomer.mutate(
{
id,
data: {
firstname: firstname || '',
lastname: rest.join(' '),
phone: form.phone || '',
phone: cleanPhone,
email: form.email || '',
},
},
@@ -180,7 +203,7 @@ export default function Customers({ embedded = false }) {
)}
</div>
<p className="text-caption text-ink-3 truncate max-w-xs" title={addr || `ID #${custId(row) ?? '—'}`}>
{addr ? `📍 ${addr}` : `ID #${custId(row) ?? '—'}`}
{addr || `ID #${custId(row) ?? '—'}`}
</p>
</div>
</div>
@@ -273,7 +296,7 @@ export default function Customers({ embedded = false }) {
<ListToolbar
search={search}
onSearchChange={setSearch}
placeholder="Search by name, phone or email"
placeholder="Search by name, phone, email or address"
count={{ shown: rows.length, total: mergedCustomers.length, noun: 'customers' }}
actions={
<Button onClick={() => navigate('/doormile/customer/create')}>

View File

@@ -34,14 +34,19 @@ import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib
import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions';
import AIAddressStep from '@/components/assistant/DoormileAI/AIAddressStep';
import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow';
import { answerAssignStep } from '@/lib/assistant/assignFlow';
import { answerAssignStep, startAssignFlow } from '@/lib/assistant/assignFlow';
import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions';
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
import { startCustomerFlow, answerStep as answerCustomerStep } from '@/lib/assistant/customerFlow';
import { CREATE_CUSTOMER_TRIGGER, executeCreateCustomer } from '@/lib/assistant/actions';
import { friendlyErrorMessage, MILETRUTH_HOME_CHATS_KEY } from '@/api/doormile/client';
import { answerSmallTalk, CAPABILITIES } from '@/lib/assistant/smalltalk';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
const MILETRUTH_SUGGESTIONS = [
"Repeat yesterday's orders",
'Create a new order',
'Create a new customer',
'How many orders today?',
'How many riders are active?',
'Current hub status',
@@ -52,6 +57,107 @@ const MILETRUTH_SUGGESTIONS = [
'How many consignments do we have?'
];
// ---- Link buttons under an answer ------------------------------------------
//
// Chosen by the intent that answered, not by words in the question. The old
// keyword test gave any question containing "create" the two order buttons —
// including "create a customer" and "what did we create yesterday".
const ACTIONS_BY_INTENT = [
[/^(?:riderCounts|riderLookup|riderActivity)$/, [['Open Control X', '/doormile/control-x'], ['View Milers', '/doormile/milers']]],
[/^(?:delayedOrders|exceptionStatus)$/, [['Inspect Exceptions', '/doormile/exceptions'], ['Open Control X', '/doormile/control-x']]],
[/^(?:tenantList|tenantDetail|tenantCount|partnerCount)$/, [['Open Clients', '/doormile/tenants'], ['View Bookings', '/doormile/bookings']]],
[/^(?:hubLookup|hubStatus)$/, [['Open Hubs', '/doormile/hubs'], ['Open Control X', '/doormile/control-x']]],
[/^(?:vehicleLookup|vehicleStatus)$/, [['Open Vehicles', '/doormile/vehicles'], ['Open Control X', '/doormile/control-x']]],
[/^tripsheetStatus$/, [['Open Tripsheets', '/doormile/tripsheets'], ['Open Control X', '/doormile/control-x']]],
[/^appUserCount$/, [['Open App Users', '/doormile/app-users']]],
[/^customerCount$/, [['Open Customers', '/doormile/customers'], ['New customer', '/doormile/customer/create']]],
[/^(?:pricingCount|carrierPricingCount)$/, [['Open Pricing', '/doormile/pricing']]],
[/^revenueTotal$/, [['View Profitability Report', '/doormile/reports/profitability'], ['View Orders', '/doormile/orders']]],
[/^competitorBranchCount$/, [['Open Competitive Intel', '/doormile/competitive-intel']]],
[/^consignmentStatus$/, [['View Deliveries', '/doormile/deliveries'], ['View Orders', '/doormile/orders']]]
];
const DEFAULT_ACTIONS = [['View Orders', '/doormile/orders'], ['Open Control X', '/doormile/control-x']];
// In this console a "client" is a business (a tenant — the Clients page), not
// an end customer. CREATE_CUSTOMER_TRIGGER accepts "client" as well (it is
// shared with the side panel), so "create a new client" would otherwise start
// the customer steps and save a business as someone's delivery recipient.
const namesClientNotCustomer = (text) => /\bclients?\b/i.test(text) && !/\bcustomers?\b/i.test(text);
const CLIENT_REDIRECT_TURN = (userQuery) => ({
id: Date.now(),
userQuery,
title: 'New clients are added on the Clients page.',
summary:
'A client is a business you deliver for — it needs its own pricing and pickup locations, which the Add Client form sets up. If you meant an end customer who receives deliveries, say “create a new customer”.',
actions: [
{ label: 'Add a client', path: '/doormile/clients/create' },
{ label: 'Create a customer instead', path: '/doormile/customer/create' }
],
timestamp: dayjs().format('hh:mm A')
});
const actionsForIntent = (intentId) => {
const hit = ACTIONS_BY_INTENT.find(([re]) => re.test(String(intentId || '')));
return (hit ? hit[1] : DEFAULT_ACTIONS).map(([label, path]) => ({ label, path }));
};
// ---- Chat history ------------------------------------------------------------
//
// The History button used to show a "Conversation history synchronized" toast
// and do nothing else — there was no history. Chats are now kept in this
// browser (last 20). The key is in DOORMILE_ASSISTANT_KEYS, so sign-out clears
// it: threads hold customer names and phone numbers, and dispatch terminals
// are shared.
const MAX_SAVED_CHATS = 20;
const MAX_TURNS_PER_CHAT = 40;
const loadChats = () => {
try {
const raw = JSON.parse(localStorage.getItem(MILETRUTH_HOME_CHATS_KEY) || '[]');
return Array.isArray(raw) ? raw : [];
} catch {
return [];
}
};
const saveChats = (chats) => {
try {
localStorage.setItem(MILETRUTH_HOME_CHATS_KEY, JSON.stringify(chats.slice(0, MAX_SAVED_CHATS)));
} catch {
/* storage full or blocked — history is a convenience, never an error */
}
};
// A saved turn must be plain data. Flow steps carry functions (options,
// validate) that JSON drops, and an open confirm button restored from history
// would let an old order be submitted again — so steps are removed and any
// unanswered confirmation is closed as "nothing was sent".
const sanitizeTurn = (turn) => {
const { flowStep: _step, flowDraft: _draft, ...rest } = turn;
if (rest.form) {
const { draft: _d, altDraft: _a, ...form } = rest.form;
rest.form = ['open', 'running'].includes(form.status) ? { ...form, status: 'cancelled' } : form;
}
return rest;
};
const chatTitle = (turns) => {
const first = turns.find((t) => t.userQuery);
return (first?.userQuery || turns[0]?.title || 'Conversation').slice(0, 80);
};
/** Field names in the create-customer payload, as the operator reads them. */
const CUSTOMER_FIELD_LABELS = {
firstname: 'First name',
lastname: 'Last name',
phone: 'Mobile',
email: 'Email',
address: 'Address',
city: 'City',
postcode: 'Pincode'
};
/**
* Turns one assistant answer into the flow diagram and breakdown table this
* page renders beside it.
@@ -373,6 +479,25 @@ export default function Home() {
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [suggestions, setSuggestions] = useState(MILETRUTH_SUGGESTIONS);
const [flow, setFlow] = useState(null);
const [chatId, setChatId] = useState(() => Date.now());
const [savedChats, setSavedChats] = useState(loadChats);
const [historyOpen, setHistoryOpen] = useState(false);
// Keep the current chat in history as it grows.
useEffect(() => {
if (!conversation.length) return;
setSavedChats((prev) => {
const entry = {
id: chatId,
title: chatTitle(conversation),
updatedAt: new Date().toISOString(),
turns: conversation.slice(-MAX_TURNS_PER_CHAT).map(sanitizeTurn)
};
const next = [entry, ...prev.filter((c) => c.id !== chatId)];
saveChats(next);
return next;
});
}, [conversation, chatId]);
const inputRef = useRef(null);
const messagesEndRef = useRef(null);
@@ -633,6 +758,47 @@ export default function Home() {
setFlow(null);
};
// One question of a step-by-step flow (assign a rider, create an order…).
const pushFlowStep = (next, userQuery = '', summary = '') => {
const stepTurn = {
id: Date.now(),
userQuery,
title: next.ask || next.step?.ask,
summary,
flowStep: next.step,
flowDraft: next.flow?.draft,
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, stepTurn]);
setActiveResponse(stepTurn);
};
// The customer flow has everything it needs — show exactly what will be
// saved and wait for the Create button. Nothing is sent before that.
const showCustomerConfirm = (payload, userQuery = '') => {
const name = [payload.firstname, payload.lastname].filter(Boolean).join(' ');
const confirmTurn = {
id: Date.now(),
userQuery,
title: `Ready to create ${name}. Check the details, then press Create customer.`,
itemsTitle: 'Customer details',
itemsList: Object.entries(payload)
.filter(([, v]) => v !== '' && v !== undefined && v !== null)
.map(([k, v]) => ({ label: CUSTOMER_FIELD_LABELS[k] || k, meta: String(v) })),
form: {
kind: 'confirmCustomer',
status: 'open',
draft: payload,
confirmLabel: 'Create customer',
runningLabel: 'Saving the customer…',
doneLabel: '✓ Customer created'
},
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, confirmTurn]);
setActiveResponse(confirmTurn);
};
const chooseStep = async (turn, value, option) => {
if (!flow || flow.complete) return;
setConversation((prev) =>
@@ -760,6 +926,45 @@ export default function Home() {
return;
}
if (turn.form.kind === 'confirmCustomer') {
const payload = turn.form.draft;
const name = [payload.firstname, payload.lastname].filter(Boolean).join(' ');
const res = await executeCreateCustomer(payload);
if (res.ok) {
queryClient.invalidateQueries();
setFlow(null);
setConversation((prev) =>
prev.map((t) => (t.id === targetId ? { ...t, form: { ...t.form, status: 'done' } } : t))
);
OpenToast(`${name} has been added to your customers.`, 'success', 3500);
const donePayload = {
id: Date.now(),
userQuery: '',
title: `${name} has been added to your customers.`,
summary: `You can find them on the Customers page${res.id ? ` (customer ID ${res.id})` : ''}, and pick them as the recipient when you create an order.`,
actions: [
{ label: 'Open Customers', path: '/doormile/customers' },
{ label: 'Create an order', path: '/doormile/orders/create' }
],
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, donePayload]);
setActiveResponse(donePayload);
} else {
// res.message names endpoints and status codes (it serves the side
// panel's developer view); the operator gets the plain version.
const message = friendlyErrorMessage(
{ httpStatus: res.status, message: res.serverMessage || '' },
`create ${name || 'this customer'}`
);
setConversation((prev) =>
prev.map((t) => (t.id === targetId ? { ...t, form: { ...t.form, status: 'failed', error: message } } : t))
);
OpenToast(message, 'error', 7000);
}
return;
}
if (turn.form.kind === 'confirmAssign') {
const { booking, __rider: rider } = turn.form.draft;
const res = await executeAssign(booking, rider);
@@ -790,7 +995,7 @@ export default function Home() {
const text = promptText || query;
if (!text.trim() || isThinking) return;
const userMessage = text.trim();
let userMessage = text.trim();
setQuery('');
setIsThinking(true);
setShowSources(false);
@@ -834,6 +1039,41 @@ export default function Home() {
return;
}
// Create-customer is typed answers only (name, phone, email…). A reply
// like "9876543210" must reach the flow, never the intent router,
// which would match nothing and drop it (see customerFlow.js).
if (flow.kind === 'createCustomer') {
const next = answerCustomerStep(flow, userMessage);
setFlow(next.flow);
if (next.done) {
showCustomerConfirm(next.payload, userMessage);
} else {
const stepTurn = {
id: Date.now(),
userQuery: userMessage,
title: next.ask,
summary: 'Type your answer below, or type “cancel” to stop.',
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, stepTurn]);
setActiveResponse(stepTurn);
}
setIsThinking(false);
return;
}
// Assign-rider: a typed booking number or rider name. Without this
// branch the reply fell through to the question router and was lost —
// only the dropdown worked.
if (flow.kind === 'assignRider') {
const next = await answerAssignStep(flow, userMessage);
setFlow(next.flow);
if (next.done) await finishAssign(next.draft);
else pushFlowStep(next, userMessage);
setIsThinking(false);
return;
}
if (flow.kind === 'createOrder') {
const next = await answerOrderStep(flow, userMessage);
setFlow(next.flow);
@@ -856,6 +1096,66 @@ export default function Home() {
}
}
// Greetings and "what can you do?". Checked after the mid-flow handling
// (a "hi" typed mid-order is an answer to that step, not a greeting)
// and before every trigger, so "good afternoon" is never sent to the
// intent router, which would match nothing and report a failure.
let firstName = '';
try {
firstName = localStorage.getItem('firstname') || '';
} catch {
/* storage blocked — greet without a name */
}
const small = answerSmallTalk(userMessage, { name: firstName });
if (small && small.kind !== 'passthrough') {
const smallTurn = {
id: Date.now(),
userQuery: userMessage,
title: small.title,
summary: small.summary,
capabilities: small.capabilities || null,
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, smallTurn]);
setActiveResponse(smallTurn);
setSuggestions(MILETRUTH_SUGGESTIONS);
if (isSpeaking) speakText(`${small.title} ${small.summary}`);
setIsThinking(false);
return;
}
// "Hi, how many orders today?" — drop the greeting, answer the question.
if (small?.kind === 'passthrough') userMessage = small.rest;
// Create customer trigger. Checked before the repeat and order
// triggers: "add a new customer" must never start an order.
if (CREATE_CUSTOMER_TRIGGER.test(userMessage) && namesClientNotCustomer(userMessage)) {
const clientTurn = CLIENT_REDIRECT_TURN(userMessage);
setConversation((prev) => [...prev, clientTurn]);
setActiveResponse(clientTurn);
setIsThinking(false);
return;
}
if (CREATE_CUSTOMER_TRIGGER.test(userMessage)) {
const started = startCustomerFlow(userMessage);
setFlow(started.flow);
if (started.done) {
showCustomerConfirm(started.payload, userMessage);
} else {
const flowTurn = {
id: Date.now(),
userQuery: userMessage,
title: started.ask,
summary:
'I’ll ask for the name and 10-digit mobile number (both required), then email, address, city and pincode — type “skip” for any of those. Nothing is saved until you press Create customer.',
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, flowTurn]);
setActiveResponse(flowTurn);
}
setIsThinking(false);
return;
}
// Repeat Runs flow trigger
if (
userMessage.toLowerCase().includes('repeat') ||
@@ -904,31 +1204,66 @@ export default function Home() {
// 2. Initial query answering via intent engine
const res = await answerQuestion(userMessage, context);
// The router recognised a write — "assign a rider to DM-BK-…", "book a
// delivery", "upload bulk orders". The home page used to ignore the form
// these return and print the headline with unrelated links, so nothing
// happened. Start the matching flow instead.
if (res?.form?.kind === 'assignRider') {
const started = await startAssignFlow(res.form.booking || null);
setFlow(started.flow);
if (started.done) await finishAssign(started.draft);
else pushFlowStep(started, userMessage, 'Pick from the list, or type the answer below. Type “cancel” to stop.');
setIsThinking(false);
return;
}
if (res?.form?.kind === 'createOrder') {
const started = await startOrderFlow();
setFlow(started.flow);
pushFlowStep(started, userMessage, 'Gathering booking and route configuration.');
setIsThinking(false);
return;
}
if (res?.form?.kind === 'createCustomer' && namesClientNotCustomer(userMessage)) {
const clientTurn = CLIENT_REDIRECT_TURN(userMessage);
setConversation((prev) => [...prev, clientTurn]);
setActiveResponse(clientTurn);
setIsThinking(false);
return;
}
if (res?.form?.kind === 'createCustomer') {
const started = startCustomerFlow(userMessage);
setFlow(started.flow);
if (started.done) showCustomerConfirm(started.payload, userMessage);
else pushFlowStep({ ask: started.ask }, userMessage, 'Type your answer below, or type “cancel” to stop.');
setIsThinking(false);
return;
}
if (res?.form?.kind === 'createBulkOrders') {
// Bulk needs a file picker, a locating pass and a per-row review,
// which the Bulk Orders page does. Say so and link it rather than
// pretending the upload happened.
const bulkTurn = {
id: Date.now(),
userQuery: userMessage,
title: 'Bulk orders are uploaded on the Bulk Orders page.',
summary:
'Upload your Excel or CSV sheet there — it checks every row, finds each address on the map and prices it, and shows you what will be created before anything is sent. You can also do it step by step in the MileTruth side panel.',
actions: [
{ label: 'Open Bulk Orders', path: '/doormile/orders/multiple' },
{ label: 'Create a single order', path: '/doormile/orders/create' }
],
timestamp: dayjs().format('hh:mm A')
};
setConversation((prev) => [...prev, bulkTurn]);
setActiveResponse(bulkTurn);
setIsThinking(false);
return;
}
if (res) {
setContext({ lastIntentId: res.intentId, lastParams: res.params });
// Build dynamic action deep-links based on intent
const actions = [];
const intent = String(res.intentId || '').toLowerCase();
if (intent.includes('create') || userMessage.toLowerCase().includes('create')) {
actions.push({ label: 'Create Single Order', path: '/doormile/orders/create' });
actions.push({ label: 'Upload Bulk Orders', path: '/doormile/orders/multiple' });
} else if (intent.includes('order') || intent.includes('booking')) {
actions.push({ label: 'Open Orders Pipeline', path: '/doormile/orders' });
actions.push({ label: 'View Control X', path: '/doormile/control-x' });
} else if (intent.includes('rider') || intent.includes('miler') || intent.includes('workforce') || intent.includes('coverage')) {
actions.push({ label: 'Open Control X', path: '/doormile/control-x' });
actions.push({ label: 'View Milers Directory', path: '/doormile/milers' });
} else if (intent.includes('exception') || intent.includes('attention') || intent.includes('stalled')) {
actions.push({ label: 'Inspect Exceptions', path: '/doormile/exceptions' });
actions.push({ label: 'Open Control X', path: '/doormile/control-x' });
} else if (intent.includes('pricing') || intent.includes('cost') || intent.includes('profit')) {
actions.push({ label: 'View Profitability Report', path: '/doormile/reports/profitability' });
actions.push({ label: 'Open Pricing Matrix', path: '/doormile/pricing' });
} else {
actions.push({ label: 'Open Operations Board', path: '/doormile/control-x' });
actions.push({ label: 'View Orders Pipeline', path: '/doormile/orders' });
}
const actions = actionsForIntent(res.intentId);
const visualData = deriveVisualData(res, userMessage);
// Optional now: deriveVisualData returns null when the answer carries
@@ -984,10 +1319,11 @@ export default function Home() {
const responsePayload = {
id: Date.now(),
userQuery: userMessage,
title: "I could not answer that from live data.",
title: 'Sorry, I didn’t understand that one yet.',
summary:
'Nothing in the intent catalogue matched this question, so there is no figure to show. ' +
'Try naming a date, a status or a client — or open the board below and read it directly.',
'I answer from live console data, so I only reply when I’m sure what you mean — I’d rather say so than guess a number. ' +
'Try rephrasing with a date, a status or a client name (for example “cancelled orders yesterday”), or pick one of the examples below.',
capabilities: CAPABILITIES,
sourceCalls: [],
actions: [
{ label: 'Open Control X', path: '/doormile/control-x' },
@@ -1012,9 +1348,9 @@ export default function Home() {
const errorPayload = {
id: Date.now(),
userQuery: userMessage,
title: 'That query failed.',
summary: err?.message
? `The request did not complete: ${err.message}`
title: 'Sorry, I couldn’t get that answer.',
summary: err
? friendlyErrorMessage(err, 'load that answer')
: 'The request did not complete. Nothing was read, so there are no figures to show.',
visualData: null,
sourceCalls: [],
@@ -1100,7 +1436,25 @@ export default function Home() {
});
};
const openSavedChat = (chat) => {
setHistoryOpen(false);
setFlow(null);
setChatId(chat.id);
setConversation(chat.turns || []);
setActiveResponse((chat.turns || [])[chat.turns.length - 1] || null);
setSuggestions(MILETRUTH_SUGGESTIONS);
};
const clearSavedChats = () => {
setSavedChats([]);
saveChats([]);
setHistoryOpen(false);
OpenToast('Chat history cleared from this browser.', 'info', 2500);
};
const resetConversation = () => {
setChatId(Date.now());
setHistoryOpen(false);
setActiveResponse(null);
setConversation([]);
setQuery('');
@@ -1202,6 +1556,16 @@ export default function Home() {
<Package className="w-4 h-4 text-emerald-500" />
Create Single Order
</button>
<button
onClick={() => {
setPlusMenuOpen(false);
navigate('/doormile/customer/create');
}}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-slate-700 hover:bg-slate-50 rounded-xl font-medium text-left cursor-pointer"
>
<Users className="w-4 h-4 text-amber-500" />
Create Customer
</button>
<button
onClick={() => {
setPlusMenuOpen(false);
@@ -1212,6 +1576,27 @@ export default function Home() {
<Clock className="w-4 h-4 text-purple-500" />
Launch Control X
</button>
{savedChats.length > 0 && (
<>
<div className="my-1 h-px bg-slate-100" />
<div className="px-3 pt-1.5 pb-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">
Recent chats
</div>
{savedChats.slice(0, 3).map((chat) => (
<button
key={chat.id}
onClick={() => {
setPlusMenuOpen(false);
openSavedChat(chat);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 text-slate-700 hover:bg-slate-50 rounded-xl font-medium text-left cursor-pointer"
>
<History className="w-4 h-4 text-slate-400 shrink-0" />
<span className="truncate">{chat.title}</span>
</button>
))}
</>
)}
</div>
</>
)}
@@ -1285,14 +1670,57 @@ export default function Home() {
{/* Right: Controls & Workspace Mode */}
<div className="flex items-center gap-3 text-slate-400">
<button
type="button"
onClick={() => OpenToast('Conversation history synchronized', 'info', 2000)}
title="History"
className="p-1 hover:text-slate-700 transition-colors cursor-pointer"
>
<History className="w-4 h-4" />
</button>
<div className="relative">
<button
type="button"
onClick={() => setHistoryOpen((v) => !v)}
title="Recent chats"
className="p-1 hover:text-slate-700 transition-colors cursor-pointer"
>
<History className="w-4 h-4" />
</button>
{historyOpen && (
<>
<div className="fixed inset-0 z-40 bg-transparent" onClick={() => setHistoryOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-72 bg-white border border-slate-200 rounded-2xl shadow-xl p-2 z-50 text-xs text-left">
<div className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-slate-400">
Recent chats
</div>
{savedChats.length === 0 ? (
<p className="px-2.5 py-3 text-slate-500">No saved chats yet. Your conversations will appear here.</p>
) : (
<>
<div className="max-h-72 overflow-y-auto">
{savedChats.map((chat) => (
<button
key={chat.id}
type="button"
onClick={() => openSavedChat(chat)}
className={cn(
'w-full px-2.5 py-2 rounded-xl hover:bg-slate-50 text-left cursor-pointer',
chat.id === chatId && 'bg-slate-50'
)}
>
<div className="font-semibold text-slate-800 truncate">{chat.title}</div>
<div className="text-[10px] text-slate-400 mt-0.5">
{dayjs(chat.updatedAt).format('D MMM, hh:mm A')} · {chat.turns?.length || 0} messages
</div>
</button>
))}
</div>
<button
type="button"
onClick={clearSavedChats}
className="w-full mt-1 px-2.5 py-2 rounded-xl text-rose-600 hover:bg-rose-50 font-semibold text-left cursor-pointer"
>
Clear history
</button>
</>
)}
</div>
</>
)}
</div>
<button
type="button"
onClick={resetConversation}
@@ -1356,6 +1784,36 @@ export default function Home() {
</p>
)}
{/* What MileTruth can do — shown for greetings, "what can
you do?" and anything it didn't understand. Every
example is a real question, sent when clicked. */}
{turn.capabilities && turn.capabilities.length > 0 && (
<div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2.5 mb-3.5">
{turn.capabilities.map((group) => (
<div
key={group.title}
className="border border-slate-200/90 rounded-2xl bg-white p-3.5 shadow-3xs"
>
<div className="text-xs font-bold text-slate-900">{group.title}</div>
<p className="text-[11px] text-slate-500 mt-0.5 mb-2.5 leading-relaxed">{group.description}</p>
<div className="flex flex-wrap gap-1.5">
{group.examples.map((example) => (
<button
key={example}
type="button"
disabled={isThinking}
onClick={() => handleAsk(example)}
className="px-2.5 py-1 rounded-full border border-slate-200 bg-slate-50 hover:bg-white hover:border-brand/40 hover:text-brand text-[11px] font-medium text-slate-700 transition-colors cursor-pointer disabled:opacity-50"
>
{example}
</button>
))}
</div>
</div>
))}
</div>
)}
{/* Interactive Dropdown Flow Step (e.g., Which day's orders to repeat) */}
{turn.flowStep?.type === 'select' && (
<HomeFlowDropdown
@@ -1382,7 +1840,7 @@ export default function Home() {
{turn.itemsList && turn.itemsList.length > 0 && (
<div className="w-full border border-slate-200/90 rounded-2xl bg-white p-3.5 sm:p-4 mb-3.5 shadow-3xs space-y-2">
<div className="text-xs font-bold text-slate-800 border-b border-slate-100 pb-2">
Orders to be created ({turn.itemsList.length})
{turn.itemsTitle || `Orders to be created (${turn.itemsList.length})`}
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{turn.itemsList.map((item, iIdx) => (
@@ -1396,7 +1854,7 @@ export default function Home() {
)}
{/* Write Gate & Form Action Buttons */}
{turn.form && ['confirmBulk', 'confirmOrder', 'confirmAssign'].includes(turn.form.kind) && (
{turn.form && ['confirmBulk', 'confirmOrder', 'confirmAssign', 'confirmCustomer'].includes(turn.form.kind) && (
<div className="w-full p-3.5 bg-slate-50/80 border border-slate-200/90 rounded-2xl mb-3.5 space-y-2.5">
{turn.form.status === 'open' && (
<div className="flex flex-wrap items-center gap-2">
@@ -1430,14 +1888,14 @@ export default function Home() {
{turn.form.status === 'running' && (
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
<div className="w-3.5 h-3.5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span>Submitting orders and scheduling with dispatch board...</span>
<span>{turn.form.runningLabel || 'Submitting orders and scheduling with dispatch board...'}</span>
</div>
)}
{turn.form.status === 'done' && (
<div className="text-xs font-bold text-emerald-700 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
<span>✓ Created and Scheduled</span>
<span>{turn.form.doneLabel || '✓ Created and Scheduled'}</span>
</div>
)}

View File

@@ -33,9 +33,10 @@ import L from 'leaflet';
// (Dispatch, Riders).
import 'leaflet/dist/leaflet.css';
import {
createAdminCustomer,
createExpressBooking,
createTenantCustomer,
getAdminCustomers,
getTenantCustomers,
getAdminPricing,
getAdminTenants,
getTenantLocations
@@ -333,18 +334,33 @@ export default function CreateOrder() {
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(() => {});
// Also persist to the backend, into the store the Customers page reads.
// This used to call createAdminCustomer (POST /admin/customers), which the
// server answers with 405 — so it never saved, and the .catch hid that.
//
// Now that it does save, it must not save twice: this runs after EVERY
// order placed with "save for later" ticked, and the endpoint has no
// duplicate check. Skip when the number is already a customer. Reads the
// Customers page's own cached list (same query key), so it rarely costs a
// request.
queryClient
.fetchQuery({ queryKey: ['doormile', 'tenant-customers'], queryFn: getTenantCustomers, staleTime: 60_000 })
.then((existing) => {
const known = (existing || []).some((c) => digits(c.phone || c.contactno).slice(-10) === cleanNum);
if (known) return null;
return createTenantCustomer({
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
}).then(() => queryClient.invalidateQueries({ queryKey: ['doormile', 'tenant-customers'] }));
})
.catch(() => {});
setIsSavedNow(true);
OpenToast(`Saved ${name} (${cleanNum}). You can pick them from Saved Contacts on your next order.`, 'success', 3500);

131
tests/lib/smalltalk.test.js Normal file
View File

@@ -0,0 +1,131 @@
/**
* MileTruth greetings and "what can you do?".
*
* "good afternoon" used to reach the intent router and be answered with
* "I could not answer that from live data". These pin the replacement: a
* greeting back plus the capability list — and, just as important, that real
* questions are NOT swallowed as small talk.
*/
import { answerSmallTalk, splitGreeting, CAPABILITIES } from '@/lib/assistant/smalltalk';
const at = (hour) => new Date(2026, 8, 25, hour, 0, 0);
describe('MileTruth small talk', () => {
describe('greetings', () => {
it('answers "good afternoon" with a greeting and what it can do', () => {
const res = answerSmallTalk('good afternoon', { name: 'Dharaneesh', now: at(14) });
expect(res.kind).toBe('greeting');
expect(res.title).toBe('Good afternoon, Dharaneesh! Here’s what I can do for you.');
expect(res.summary).toMatch(/MileTruth/);
expect(res.capabilities).toBe(CAPABILITIES);
});
it('mirrors the greeting the user chose, whatever the clock says', () => {
expect(answerSmallTalk('Good Morning!!', { now: at(20) }).title).toMatch(/^Good morning!/);
expect(answerSmallTalk('goodevening', { now: at(9) }).title).toMatch(/^Good evening!/);
expect(answerSmallTalk('gm', { now: at(20) }).title).toMatch(/^Good morning!/);
});
it.each([
['hi', 9, 'Good morning'],
['Hello there', 14, 'Good afternoon'],
['hey miletruth', 19, 'Good evening']
])('answers "%s" by the clock', (text, hour, expected) => {
expect(answerSmallTalk(text, { now: at(hour) }).title).toMatch(new RegExp(`^${expected}!`));
});
it('greets without a name when none is known', () => {
expect(answerSmallTalk('hi', { now: at(9) }).title).toBe('Good morning! Here’s what I can do for you.');
});
});
describe('"what can you do?"', () => {
it.each(['what can you do?', 'What is this AI', 'who are you', 'help', 'how do I use this', 'what can I ask'])(
'lists capabilities for "%s"',
(text) => {
const res = answerSmallTalk(text);
expect(res.kind).toBe('capabilities');
expect(res.capabilities.length).toBeGreaterThan(0);
}
);
// Real wordings. The first one is the message that got "Sorry, I didn't
// understand" when this matched exact sentences only.
it.each([
'what are things you can do ?',
'what are the things you can do',
'What all can you do?',
'what things do you do',
'what else can u help with',
'what kind of things can you help me with',
'can you help me',
'can u do anything',
'what can I ask you?',
'what should i ask',
'tell me your features',
'show me what you can do',
'list your capabilities',
'what is your job',
'what are you good at',
'what is this',
'what is miletruth',
'how does this work?',
'how to use this',
'features',
'menu',
"what's up"
])('lists capabilities for "%s"', (text) => {
expect(answerSmallTalk(text)?.kind).toBe('capabilities');
});
it('handles a greeting and the question together', () => {
const res = answerSmallTalk('good afternoon, what can you do?', { now: at(14) });
expect(res.kind).toBe('capabilities');
expect(res.title).toMatch(/^Good afternoon!/);
});
});
describe('real questions are left alone', () => {
it.each([
'How many orders today?',
'help me assign a rider to DM-BK-12',
'create a new order',
'Which orders are delayed?',
'what can you tell me about orders today',
'what is the status of DM-BK-0D915D43-33705',
'how many riders are active',
'show me revenue this week',
'which hub is busiest',
'what is pending',
'how do i assign a rider',
'can you show vehicles available'
])(
'returns null for "%s"',
(text) => {
expect(answerSmallTalk(text)).toBeNull();
}
);
it('strips a greeting in front of a real question and passes the question through', () => {
const res = answerSmallTalk('hi, how many orders today?');
expect(res).toEqual({ kind: 'passthrough', greeting: expect.any(String), rest: 'how many orders today?' });
});
it('does not read a word that merely starts like a greeting as one', () => {
expect(splitGreeting('history of orders').greeting).toBeNull();
expect(splitGreeting('highest revenue client').greeting).toBeNull();
});
});
it('answers thanks briefly, without the whole list', () => {
const res = answerSmallTalk('thank you');
expect(res.kind).toBe('thanks');
expect(res.capabilities).toBeUndefined();
});
it('only offers examples the home page actually handles', () => {
const examples = CAPABILITIES.flatMap((g) => g.examples);
expect(examples).toEqual(expect.arrayContaining(['Create a new order', 'Create a new customer', 'How many orders today?']));
expect(examples.every((e) => typeof e === 'string' && e.length > 3)).toBe(true);
});
});