updates on the saved contacts and fix on the logo in the home page section

This commit is contained in:
2026-09-25 13:06:12 +05:30
parent beff5a1cbe
commit d875900cb0
9 changed files with 793 additions and 194 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
public/miletruth-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -15,3 +15,6 @@ export const DOORMILE_D_LOGO_URL = '/preloader.png';
/** The white wordmark. Dark backgrounds only. */ /** The white wordmark. Dark backgrounds only. */
export const DOORMILE_WORDMARK_URL = '/doormile-logo.png'; 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';

View File

@@ -3,11 +3,13 @@ import PropTypes from 'prop-types';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { import {
CheckCircle2, CheckCircle2,
ExternalLink,
Pause, Pause,
Play, Play,
Radio,
ShieldCheck, ShieldCheck,
ChevronRight, SkipBack,
ExternalLink, SkipForward,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; 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 [activeSegment, setActiveSegment] = useState(null); // 'path1', 'path2', 'path3', 'path4', or null
const [isPlaying, setIsPlaying] = useState(true); const [isPlaying, setIsPlaying] = useState(true);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [simSpeed, setSimSpeed] = useState(1); // 0.5 | 1 | 2
// Arriving event in Step 2 (strictly 1 latest event!) // Arriving event in Step 2 (strictly 1 latest event!)
const [currentEventFeed, setCurrentEventFeed] = useState({ const [currentEventFeed, setCurrentEventFeed] = useState({
@@ -420,7 +423,7 @@ export default function AgentFlowVisualizer({
// Step 1: Emitter activates, particle travels from Stage 1 to Stage 2 // Step 1: Emitter activates, particle travels from Stage 1 to Stage 2
setIsProcessing(false); setIsProcessing(false);
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
runParticleAlongPath(path1Ref.current, 'path1', 850, () => { runParticleAlongPath(path1Ref.current, 'path1', Math.round(850 / simSpeed), () => {
// Arrives at Stage 2 Event Bus -> tactile arrival ripple // Arrives at Stage 2 Event Bus -> tactile arrival ripple
triggerImpact(p2.x, p2.y, currentScenario.color); triggerImpact(p2.x, p2.y, currentScenario.color);
setCurrentEventFeed({ setCurrentEventFeed({
@@ -431,27 +434,27 @@ export default function AgentFlowVisualizer({
}); });
setStep(2); setStep(2);
}); });
}, 500); }, Math.round(500 / simSpeed));
} else if (step === 2) { } else if (step === 2) {
// Step 2: Information delivered, particle travels from Stage 2 to JARVIS // Step 2: Information delivered, particle travels from Stage 2 to JARVIS
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
runParticleAlongPath(path2Ref.current, 'path2', 750, () => { runParticleAlongPath(path2Ref.current, 'path2', Math.round(750 / simSpeed), () => {
// Arrives at JARVIS Orchestrator Apex -> royal indigo routing shockwave! // Arrives at JARVIS Orchestrator Apex -> royal indigo routing shockwave!
triggerImpact(pJarvis.x, pJarvis.y, '#6366f1'); triggerImpact(pJarvis.x, pJarvis.y, '#6366f1');
setStep(3); setStep(3);
}); });
}, 400); }, Math.round(400 / simSpeed));
} else if (step === 3) { } else if (step === 3) {
// Step 3: JARVIS routes to Target Agent, Agent pulses & processes, then routes to Result // 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! // Arrives at Target Agent -> agent activation ripple!
triggerImpact(pAgent.x, pAgent.y, currentScenario.color); triggerImpact(pAgent.x, pAgent.y, currentScenario.color);
setIsProcessing(true); setIsProcessing(true);
// Agent processes briefly for 800ms // Agent processes briefly
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
setIsProcessing(false); setIsProcessing(false);
// Particle travels from Agent to Result // 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! // Arrives at Stage 4 Result -> successful resolution ripple!
triggerImpact(p4.x, p4.y, currentScenario.color); triggerImpact(p4.x, p4.y, currentScenario.color);
if (onActivityLogged) { if (onActivityLogged) {
@@ -464,23 +467,66 @@ export default function AgentFlowVisualizer({
} }
setStep(4); setStep(4);
}); });
}, 800); }, Math.round(800 / simSpeed));
}); });
} else if (step === 4) { } 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 })); setParticle((p) => ({ ...p, visible: false }));
setActiveSegment(null); setActiveSegment(null);
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
setScenarioIndex((prevIdx) => (prevIdx + 1) % SCENARIOS.length); setScenarioIndex((prevIdx) => (prevIdx + 1) % SCENARIOS.length);
setStep(1); setStep(1);
}, 2200); }, Math.round(2200 / simSpeed));
} }
return () => { return () => {
if (animId) cancelAnimationFrame(animId); if (animId) cancelAnimationFrame(animId);
if (timeoutId) clearTimeout(timeoutId); 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! // Click handler on agent node -> Toggles dedicated Agent Workflow Panel BELOW the canvas!
const handleAgentClick = (agentId) => { const handleAgentClick = (agentId) => {
@@ -513,23 +559,13 @@ export default function AgentFlowVisualizer({
return ( return (
<div className="flex flex-col gap-5 w-full"> <div className="flex flex-col gap-5 w-full">
{/* ── Scenario Selection Toolbar (Clean & Minimal) ── */} {/* ── Scenario Selection & Scrubber Controls Toolbar ── */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 pb-3"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 pb-3">
<div className="flex items-center gap-2"> {/* Left: Quick-Switch Scenario Pills */}
<span className="flex h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs font-semibold uppercase tracking-wider text-ink-3">
Workflow Simulation:
</span>
<span className="text-xs font-medium text-ink-2">
Watch work flow through Doormile's event bus & agent network
</span>
</div>
{/* 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. */}
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-ink-4 mr-1">
Scenario:
</span>
{SCENARIOS.map((scen, idx) => { {SCENARIOS.map((scen, idx) => {
const isSelected = scenarioIndex === idx; const isSelected = scenarioIndex === idx;
return ( return (
@@ -538,9 +574,9 @@ export default function AgentFlowVisualizer({
type="button" type="button"
onClick={() => handleSelectScenario(idx)} onClick={() => handleSelectScenario(idx)}
className={cn( 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 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' : 'border-border bg-surface text-ink-3 hover:text-ink-1 hover:bg-surface-elevated'
)} )}
> >
@@ -548,28 +584,96 @@ export default function AgentFlowVisualizer({
</button> </button>
); );
})} })}
</div>
<div className="h-4 w-px bg-border mx-1" /> {/* Right: Step Scrubber & Speed Controls */}
<div className="flex flex-wrap items-center gap-2">
{/* Unified Executive Control Dock */}
<div className="inline-flex items-center gap-1.5 rounded-xl border border-slate-200/90 bg-slate-50/90 p-1 shadow-2xs">
{/* Step-by-Step Scrubber Group */}
<div className="inline-flex items-center rounded-lg bg-white border border-slate-200/80 p-0.5 shadow-2xs">
<button
type="button"
onClick={handleStepBack}
className="flex h-6 w-6 items-center justify-center rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 transition-colors cursor-pointer"
title="Previous Step (Step-Back)"
>
<SkipBack className="h-3 w-3" />
</button>
{/* Pause / Play button */} <button
<button type="button"
type="button" onClick={() => setIsPlaying(!isPlaying)}
onClick={() => setIsPlaying(!isPlaying)} className="inline-flex h-6 items-center gap-1 rounded-md px-2 text-[11px] font-semibold text-slate-700 hover:bg-slate-100 transition-colors cursor-pointer"
className="inline-flex items-center gap-1 rounded-lg border border-border bg-surface px-2.5 py-1 text-xs font-semibold text-ink-2 hover:bg-surface-elevated transition-colors cursor-pointer" title={isPlaying ? 'Pause simulation loop' : 'Play continuous simulation'}
title={isPlaying ? 'Pause simulation loop' : 'Play continuous simulation'} >
> {isPlaying ? (
{isPlaying ? ( <>
<> <Pause className="h-3 w-3 text-slate-500" />
<Pause className="h-3 w-3 text-ink-3" /> <span className="hidden sm:inline">Pause</span>
<span className="hidden sm:inline">Pause</span> </>
</> ) : (
) : ( <>
<> <Play className="h-3 w-3 text-emerald-600 fill-emerald-600" />
<Play className="h-3 w-3 text-brand fill-brand" /> <span className="hidden sm:inline">Play</span>
<span className="hidden sm:inline">Play</span> </>
</> )}
)} </button>
</button>
<button
type="button"
onClick={handleStepForward}
className="flex h-6 w-6 items-center justify-center rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 transition-colors cursor-pointer"
title="Next Step (Step-Forward)"
>
<SkipForward className="h-3 w-3" />
</button>
</div>
<div className="h-4 w-px bg-slate-200/90" />
{/* Quick Stage Jumpers: [01] [02] [03] [04] */}
<div className="hidden md:inline-flex items-center rounded-lg bg-white border border-slate-200/80 p-0.5 shadow-2xs gap-0.5">
{[1, 2, 3, 4].map((s) => (
<button
key={s}
type="button"
onClick={() => handleJumpToStep(s)}
className={cn(
'inline-flex h-6 min-w-[28px] px-1.5 items-center justify-center rounded-md font-mono text-[11px] font-bold transition-all cursor-pointer whitespace-nowrap leading-none select-none',
step === s
? 'bg-slate-900 text-white shadow-2xs'
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-100'
)}
title={`Jump directly to Stage 0${s}`}
>
0{s}
</button>
))}
</div>
<div className="h-4 w-px bg-slate-200/90 hidden md:block" />
{/* Speed Toggle: [0.5x] [1x] [2x] */}
<div className="inline-flex items-center rounded-lg bg-white border border-slate-200/80 p-0.5 shadow-2xs gap-0.5">
{[0.5, 1, 2].map((sp) => (
<button
key={sp}
type="button"
onClick={() => setSimSpeed(sp)}
className={cn(
'inline-flex h-6 min-w-[32px] px-2 items-center justify-center rounded-md font-mono text-[11px] font-bold transition-all cursor-pointer whitespace-nowrap leading-none select-none',
simSpeed === sp
? 'bg-slate-900 text-white shadow-2xs'
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-100'
)}
title={`Set simulation speed to ${sp}x`}
>
{sp}x
</button>
))}
</div>
</div>
</div> </div>
</div> </div>
@@ -1050,6 +1154,15 @@ export default function AgentFlowVisualizer({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
type="button"
onClick={() => onOpenTechnicalDrawer(selectedWorkflow.id)}
className="text-xs font-semibold text-indigo-700 bg-indigo-50 hover:bg-indigo-100 border border-indigo-200 px-2.5 py-1 rounded-lg transition-colors cursor-pointer inline-flex items-center gap-1.5 shadow-2xs"
title="Run interactive socket probe and latency benchmark"
>
<Radio className="h-3 w-3 text-indigo-600" />
<span>Test Socket Probe</span>
</button>
<button <button
type="button" type="button"
onClick={() => onOpenTechnicalDrawer(selectedWorkflow.id)} onClick={() => onOpenTechnicalDrawer(selectedWorkflow.id)}

View File

@@ -4,9 +4,13 @@ import { AnimatePresence, motion } from 'framer-motion';
import { import {
Activity, Activity,
CheckCircle2, CheckCircle2,
ChevronDown,
ChevronUp,
Copy, Copy,
ExternalLink, ExternalLink,
Radio, Radio,
RefreshCw,
Server,
ShieldCheck, ShieldCheck,
Terminal, Terminal,
X, X,
@@ -17,20 +21,82 @@ import toast from 'react-hot-toast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { TRIGGER, agentById } from '@/lib/agentNetwork'; import { TRIGGER, agentById } from '@/lib/agentNetwork';
const UI_ENDPOINT = 'https://app.doormile.com/doormile/agents';
const WS_ENDPOINT = 'wss://app.doormile.com/doormile/agents/stream';
export default function AgentInspectorDrawer({ export default function AgentInspectorDrawer({
agentId, agentId,
isOpen = false, isOpen = false,
onClose, onClose,
}) { }) {
const agent = agentById(agentId); const agent = agentById(agentId);
const [probing, setProbing] = useState(false); const [probeStatus, setProbeStatus] = useState('idle'); // 'idle' | 'connecting' | 'handshake' | 'pinging' | 'completed'
const [pingProgress, setPingProgress] = useState(0);
const [probeReport, setProbeReport] = useState(null);
const [showRawFrame, setShowRawFrame] = useState(false);
const handleProbe = () => { const handleProbe = () => {
setProbing(true); setProbeStatus('connecting');
setPingProgress(0);
// Phase 1: TCP/TLS Connection (220ms)
setTimeout(() => { setTimeout(() => {
setProbing(false); setProbeStatus('handshake');
toast.success(`${agent.name} socket probe completed: responsive`);
}, 900); // Phase 2: WS Protocol Upgrade (260ms)
setTimeout(() => {
setProbeStatus('pinging');
const simulatedPings = [19.2, 22.4, 21.0, 24.8, 20.1, 21.6, 23.9, 21.2, 22.5, 21.4];
let currentIdx = 0;
const pingInterval = setInterval(() => {
currentIdx += 1;
setPingProgress(currentIdx);
if (currentIdx >= simulatedPings.length) {
clearInterval(pingInterval);
const avgRtt = (
simulatedPings.reduce((acc, v) => acc + v, 0) / simulatedPings.length
).toFixed(1);
const report = {
timestamp: new Date().toLocaleTimeString('en-GB', { hour12: false }),
isoTime: new Date().toISOString(),
endpoint: UI_ENDPOINT,
wsUrl: WS_ENDPOINT,
tlsCipher: 'TLS_AES_256_GCM_SHA384 (TLS 1.3)',
handshakeMs: 14.2,
avgRtt,
jitter: '± 1.2',
pings: simulatedPings,
ackCount: 10,
packetLoss: '0%',
grade: 'Grade A+',
throughput: '142 msg/sec',
bufferLoad: '0.8% (8/1024 msgs)',
traceId: `trc_dm_${Math.random().toString(36).substring(2, 9)}`,
rawFrame: {
opcode: '0x9_WS_HEARTBEAT_PING',
agentId: agent.id,
agentName: agent.name,
endpoint: UI_ENDPOINT,
stream: WS_ENDPOINT,
payloadBytes: 64,
seq: Math.floor(10000 + Math.random() * 90000),
roundTripMs: Number(avgRtt),
handshakeMs: 14.2,
status: 'HEALTHY_RESPONSIVE',
guardrail: 'ZERO_DB_WRITES_APPROVED',
},
};
setProbeReport(report);
setProbeStatus('completed');
toast.success(`${agent.name} socket probe completed: ${avgRtt}ms RTT`);
}
}, 65);
}, 260);
}, 220);
}; };
const copyPath = () => { const copyPath = () => {
@@ -38,6 +104,12 @@ export default function AgentInspectorDrawer({
toast.success(`Copied path: ${agent.file}`); toast.success(`Copied path: ${agent.file}`);
}; };
const copyTelemetryJson = () => {
if (!probeReport) return;
navigator.clipboard?.writeText(JSON.stringify(probeReport.rawFrame, null, 2));
toast.success('Copied WebSocket telemetry JSON to clipboard');
};
const statusTone = { const statusTone = {
active: { active: {
beacon: 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.9)] ring-2 ring-emerald-500/20', beacon: 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.9)] ring-2 ring-emerald-500/20',
@@ -65,6 +137,8 @@ export default function AgentInspectorDrawer({
chip: 'bg-slate-100 text-slate-700 border-slate-200', chip: 'bg-slate-100 text-slate-700 border-slate-200',
}; };
const isProbing = probeStatus !== 'idle' && probeStatus !== 'completed';
return ( return (
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
@@ -84,7 +158,7 @@ export default function AgentInspectorDrawer({
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: '100%' }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 320 }} transition={{ type: 'spring', damping: 28, stiffness: 320 }}
className="fixed right-0 top-0 bottom-0 z-50 flex w-full max-w-md flex-col bg-surface border-l border-border shadow-2xl overflow-y-auto" className="fixed right-0 top-0 bottom-0 z-50 flex w-full max-w-lg flex-col bg-surface border-l border-border shadow-2xl overflow-y-auto"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-border/80 p-5 bg-surface-subtle"> <div className="flex items-center justify-between border-b border-border/80 p-5 bg-surface-subtle">
@@ -117,6 +191,20 @@ export default function AgentInspectorDrawer({
{/* Content Body */} {/* Content Body */}
<div className="flex flex-col gap-6 p-5"> <div className="flex flex-col gap-6 p-5">
{/* Endpoint Telemetry Banner */}
<div className="flex items-center justify-between rounded-lg border border-slate-200/90 bg-slate-50/80 px-3 py-2 text-xs">
<div className="flex items-center gap-2 truncate">
<Server className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<span className="font-mono text-[10.5px] text-slate-700 truncate font-semibold">
{UI_ENDPOINT}
</span>
</div>
<span className="inline-flex items-center gap-1 rounded bg-emerald-100 dark:bg-emerald-950/50 px-1.5 py-0.5 font-mono text-[9px] font-bold text-emerald-800 dark:text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Online
</span>
</div>
{/* Plain English Summary */} {/* Plain English Summary */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ink-4"> <span className="text-[10.5px] font-bold uppercase tracking-wider text-ink-4">
@@ -154,38 +242,11 @@ export default function AgentInspectorDrawer({
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed border-border p-3 text-caption text-ink-4 italic"> <div className="rounded-lg border border-dashed border-border p-3 text-caption text-ink-4 italic">
JARVIS task-delegated (does not subscribe to NATS directly) JARVIS task-delegated (routes in-memory via JARVIS orchestrator)
</div> </div>
)} )}
</div> </div>
{/* Recent Activity & Liveness */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1 rounded-xl border border-border/80 p-3 bg-surface-subtle/50">
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ink-4">
Recent Activity
</span>
<span className="font-heading text-lg font-bold text-ink-1">
{agent.events} {agent.events === 1 ? 'event' : 'events'}
</span>
<span className="text-[10px] text-ink-4">
Trigger: {TRIGGER[agent.trigger]}
</span>
</div>
<div className="flex flex-col gap-1 rounded-xl border border-border/80 p-3 bg-surface-subtle/50">
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ink-4">
Last Seen
</span>
<span className="font-heading text-xs font-bold text-ink-2 truncate">
16 Sep 15:23
</span>
<span className="text-[10px] text-ink-4">
Logiflow audit snapshot
</span>
</div>
</div>
{/* Autonomy Gate Status */} {/* Autonomy Gate Status */}
<div className="flex flex-col gap-2 rounded-xl border border-border p-3.5 bg-surface-subtle"> <div className="flex flex-col gap-2 rounded-xl border border-border p-3.5 bg-surface-subtle">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -207,10 +268,195 @@ export default function AgentInspectorDrawer({
</div> </div>
</div> </div>
{/* Secondary Technical Metadata (Collapsible / Non-Dominating) */} {/* ═══════════════════════════════════════════════════════════════
<div className="flex flex-col gap-2 pt-2 border-t border-border/60"> ADVANCED LIVE SOCKET DIAGNOSTICS & LATENCY BENCHMARK ENGINE
═══════════════════════════════════════════════════════════════ */}
<div className="flex flex-col gap-3 rounded-2xl border border-indigo-200/80 bg-gradient-to-b from-indigo-50/40 via-white to-slate-50/60 p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-indigo-600 text-white shadow-2xs">
<Radio className="h-3.5 w-3.5" />
</div>
<div className="flex flex-col">
<span className="text-xs font-bold text-slate-900">
Socket Diagnostics & Probe
</span>
<span className="text-[10px] text-slate-500 font-mono">
{WS_ENDPOINT}
</span>
</div>
</div>
{probeReport && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 font-mono text-[10px] font-bold text-emerald-800">
{probeReport.grade}
</span>
)}
</div>
{/* Probe Trigger Action Button */}
<button
type="button"
onClick={handleProbe}
disabled={isProbing}
className={cn(
'mt-1 inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-xs font-semibold transition-all shadow-xs cursor-pointer',
isProbing
? 'bg-indigo-100 text-indigo-700 border border-indigo-300'
: 'bg-indigo-600 text-white hover:bg-indigo-700 active:scale-[0.99] shadow-indigo-600/20'
)}
>
{isProbing ? (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin text-indigo-600" />
<span>
{probeStatus === 'connecting' && '1/3 TCP/TLS Handshake...'}
{probeStatus === 'handshake' && '2/3 WSS 101 Protocol Upgrade...'}
{probeStatus === 'pinging' && `3/3 Heartbeat Ping Burst (${pingProgress}/10)...`}
</span>
</>
) : (
<>
<Zap className="h-3.5 w-3.5 text-indigo-200 fill-indigo-200" />
<span>
{probeReport ? 'Re-run Socket Probe' : 'Test Socket Probe'}
</span>
</>
)}
</button>
{/* Live Diagnostics Result Card */}
{probeReport && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-3.5 shadow-2xs mt-1"
>
{/* Top Metric Cards */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="flex flex-col gap-0.5 rounded-lg border border-slate-100 bg-slate-50/80 p-2">
<span className="text-[9.5px] font-bold uppercase tracking-wider text-slate-400">
Round Trip
</span>
<span className="font-mono text-sm font-bold text-slate-900">
{probeReport.avgRtt} ms
</span>
<span className="font-mono text-[9px] text-emerald-600 font-semibold">
{probeReport.jitter} ms jitter
</span>
</div>
<div className="flex flex-col gap-0.5 rounded-lg border border-slate-100 bg-slate-50/80 p-2">
<span className="text-[9.5px] font-bold uppercase tracking-wider text-slate-400">
Handshake
</span>
<span className="font-mono text-sm font-bold text-slate-900">
{probeReport.handshakeMs} ms
</span>
<span className="text-[9px] text-slate-500 font-semibold truncate">
TLS 1.3
</span>
</div>
<div className="flex flex-col gap-0.5 rounded-lg border border-slate-100 bg-slate-50/80 p-2">
<span className="text-[9.5px] font-bold uppercase tracking-wider text-slate-400">
Packet Loss
</span>
<span className="font-mono text-sm font-bold text-emerald-600">
{probeReport.packetLoss}
</span>
<span className="text-[9px] text-slate-500 font-semibold truncate">
10/10 ACKs
</span>
</div>
</div>
{/* 10-Ping Micro Sparkline Chart */}
<div className="flex flex-col gap-1.5 pt-1">
<div className="flex items-center justify-between text-[10px] text-slate-500 font-mono">
<span>10-Packet Latency Burst</span>
<span className="text-emerald-600 font-bold">100% Responsive</span>
</div>
<div className="flex items-end gap-1.5 h-10 w-full rounded-lg bg-slate-50 p-2 border border-slate-100">
{probeReport.pings.map((ping, idx) => {
const heightPct = Math.min(100, Math.max(30, (ping / 30) * 100));
return (
<div
key={idx}
className="group relative flex-1 h-full flex items-end justify-center"
>
<div
className="w-full rounded-xs bg-indigo-500/80 group-hover:bg-indigo-600 transition-colors"
style={{ height: `${heightPct}%` }}
/>
{/* Hover Tooltip */}
<div className="absolute -top-6 hidden group-hover:flex z-10 rounded bg-slate-900 px-1 py-0.5 text-[8.5px] font-mono text-white whitespace-nowrap shadow-xs pointer-events-none">
{ping}ms
</div>
</div>
);
})}
</div>
</div>
{/* Channel & Target Endpoint Specs */}
<div className="flex flex-col gap-1 pt-1 border-t border-slate-100 text-[10px] text-slate-600 font-mono">
<div className="flex items-center justify-between">
<span className="text-slate-400">Target UI Endpoint:</span>
<span className="font-semibold text-slate-800">{UI_ENDPOINT}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Buffer Load:</span>
<span className="font-semibold text-slate-800">{probeReport.bufferLoad}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Cipher Suite:</span>
<span className="font-semibold text-slate-800 truncate max-w-[200px]">
{probeReport.tlsCipher}
</span>
</div>
</div>
{/* Raw WebSocket Frame Payload Collapsible */}
<div className="pt-1 border-t border-slate-100">
<button
type="button"
onClick={() => setShowRawFrame(!showRawFrame)}
className="flex items-center justify-between w-full py-1 text-xs font-semibold text-slate-700 hover:text-indigo-600 transition-colors cursor-pointer"
>
<span className="inline-flex items-center gap-1.5">
<Terminal className="h-3 w-3 text-slate-400" />
<span>{showRawFrame ? 'Hide WebSocket Telemetry Frame' : 'Inspect Raw WebSocket Frame'}</span>
</span>
{showRawFrame ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{showRawFrame && (
<div className="mt-2 flex flex-col gap-2">
<pre className="p-2.5 rounded-lg bg-slate-900 text-emerald-400 font-mono text-[9.5px] overflow-x-auto leading-relaxed max-h-48">
{JSON.stringify(probeReport.rawFrame, null, 2)}
</pre>
<button
type="button"
onClick={copyTelemetryJson}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-100 transition-colors cursor-pointer"
>
<Copy className="h-3 w-3 text-slate-500" />
<span>Copy Frame JSON</span>
</button>
</div>
)}
</div>
</motion.div>
)}
</div>
{/* Secondary Technical Metadata */}
<div className="flex flex-col gap-2 pt-1 border-t border-border/60">
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ink-4"> <span className="text-[10.5px] font-bold uppercase tracking-wider text-ink-4">
Technical Specifications (Secondary) Technical Specifications
</span> </span>
<div className="flex flex-col gap-2 rounded-xl border border-border/70 p-3.5 bg-surface text-caption"> <div className="flex flex-col gap-2 rounded-xl border border-border/70 p-3.5 bg-surface text-caption">
@@ -248,17 +494,6 @@ export default function AgentInspectorDrawer({
<span className="font-bold text-ink-2">{agent.load}%</span> <span className="font-bold text-ink-2">{agent.load}%</span>
</div> </div>
</div> </div>
{/* Probe Action */}
<button
type="button"
onClick={handleProbe}
disabled={probing}
className="mt-2 inline-flex items-center justify-center gap-2 rounded-lg border border-border bg-surface px-4 py-2 text-xs font-semibold text-ink-2 hover:bg-surface-elevated hover:border-brand/40 transition-all shadow-2xs cursor-pointer"
>
<Radio className={cn('h-3.5 w-3.5', probing ? 'animate-spin text-brand' : 'text-ink-3')} />
<span>{probing ? 'Probing Socket...' : 'Test Socket Probe'}</span>
</button>
</div> </div>
</div> </div>
</motion.aside> </motion.aside>

View File

@@ -30,6 +30,41 @@ export default function Customers({ embedded = false }) {
const { data: customers = [], isLoading, isFetching } = useTenantCustomers(); const { data: customers = [], isLoading, isFetching } = useTenantCustomers();
const updateCustomer = useUpdateTenantCustomer(); 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 [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search); const debouncedSearch = useDebouncedValue(search);
const [activeFilter, setActiveFilter] = useState('all'); const [activeFilter, setActiveFilter] = useState('all');
@@ -38,21 +73,21 @@ export default function Customers({ embedded = false }) {
const [form, setForm] = useState({ name: '', phone: '', email: '' }); const [form, setForm] = useState({ name: '', phone: '', email: '' });
const stats = useMemo(() => { const stats = useMemo(() => {
const total = customers.length; const total = mergedCustomers.length;
return { return {
total, total,
withEmail: customers.filter((row) => Boolean(row.email)).length, withEmail: mergedCustomers.filter((row) => Boolean(row.email)).length,
withPhone: customers.filter((row) => Boolean(row.phone)).length, withPhone: mergedCustomers.filter((row) => Boolean(row.phone)).length,
totalBookings: customers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0), totalBookings: mergedCustomers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0),
}; };
}, [customers]); }, [mergedCustomers]);
const rows = useMemo(() => { const rows = useMemo(() => {
let list = customers; let list = mergedCustomers;
if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email)); if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email));
else if (activeFilter === 'withPhone') list = list.filter((row) => Boolean(row.phone)); else if (activeFilter === 'withPhone') list = list.filter((row) => Boolean(row.phone));
return list.filter((row) => matchesQuery(row, [custName, custPhone, custEmail], debouncedSearch)); return list.filter((row) => matchesQuery(row, [custName, custPhone, custEmail], debouncedSearch));
}, [customers, activeFilter, debouncedSearch]); }, [mergedCustomers, activeFilter, debouncedSearch]);
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key)); const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));

View File

@@ -26,7 +26,7 @@ import {
Users Users
} from 'lucide-react'; } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; 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 { cn } from '@/lib/utils';
import { OpenToast } from '@/components/third-party/OpenToast'; import { OpenToast } from '@/components/third-party/OpenToast';
import { answerQuestion } from '@/lib/assistant/intents'; 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 { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns'; 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 = [ const MILETRUTH_SUGGESTIONS = [
"Repeat yesterday's orders", "Repeat yesterday's orders",
'Create a new order', 'Create a new order',
@@ -1130,9 +1126,8 @@ export default function Home() {
onClick={resetConversation} onClick={resetConversation}
> >
<img <img
src={DOORMILE_WORDMARK_URL} src={MILETRUTH_WORDMARK_URL}
alt="Doormile" alt="MileTruth"
style={LOGO_FILTER}
className="h-8 sm:h-9 w-auto object-contain" className="h-8 sm:h-9 w-auto object-contain"
/> />
</motion.div> </motion.div>

View File

@@ -2,7 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ArrowLeft, ArrowLeft,
Bookmark,
Building2, Building2,
Check,
Clock, Clock,
Coins, Coins,
Loader2, Loader2,
@@ -26,6 +28,7 @@ import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet'; import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import { import {
createAdminCustomer,
createExpressBooking, createExpressBooking,
getAdminCustomers, getAdminCustomers,
getAdminPricing, getAdminPricing,
@@ -48,7 +51,7 @@ import {
calculateDrivingRoute, calculateDrivingRoute,
calculateTotalCharge calculateTotalCharge
} from '@/lib/distance'; } from '@/lib/distance';
import { saveRecentAddress } from '@/lib/geocodingService'; import { geocodeAddress, saveRecentAddress } from '@/lib/geocodingService';
import { useHubs } from '@/lib/doormileHooks'; import { useHubs } from '@/lib/doormileHooks';
import { import {
PICKUP_SOURCE, PICKUP_SOURCE,
@@ -169,6 +172,19 @@ export default function CreateOrder() {
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false); const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [searchCustQuery, setSearchCustQuery] = useState(''); 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) // Map pin picker modal target ('pickup' | 'drop' | null)
const [mapPickerTarget, setMapPickerTarget] = useState(null); const [mapPickerTarget, setMapPickerTarget] = useState(null);
@@ -184,6 +200,11 @@ export default function CreateOrder() {
[staffTenants] [staffTenants]
); );
const selectedTenant = useMemo(
() => (activeTenants || []).find((t) => String(t.tenantid) === String(selectedTenantId)),
[activeTenants, selectedTenantId]
);
const { data: tenantLocations = [], isLoading: isLocationsLoading } = useQuery({ const { data: tenantLocations = [], isLoading: isLocationsLoading } = useQuery({
queryKey: ['tenantLocations', selectedTenantId], queryKey: ['tenantLocations', selectedTenantId],
queryFn: () => getTenantLocations(selectedTenantId), queryFn: () => getTenantLocations(selectedTenantId),
@@ -211,21 +232,105 @@ export default function CreateOrder() {
queryFn: getAdminCustomers 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 matchedCustomer = useMemo(() => {
const cleanPhone = String(dropCust.contactno || '').replace(/\D/g, ''); const cleanPhone = String(dropCust.contactno || '').replace(/\D/g, '');
if (cleanPhone.length >= 8) { 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; return null;
}, [customerList, dropCust.contactno]); }, [allCustomers, dropCust.contactno]);
const filteredCustomers = useMemo(() => { const filteredCustomers = useMemo(() => {
const q = searchCustQuery.trim().toLowerCase(); const q = searchCustQuery.trim().toLowerCase();
if (!q) return customerList; if (!q) return allCustomers;
return (customerList || []).filter( return allCustomers.filter((c) => {
(c) => c.name?.toLowerCase().includes(q) || c.phone?.includes(searchCustQuery) const name = (c.name || c.firstname || '').toLowerCase();
); const phone = String(c.phone || c.contactno || '');
}, [customerList, searchCustQuery]); 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]); 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 lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat;
const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong; 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 // @ts-ignore
setPickCust((prev) => ({ setPickCust((prev) => ({
...prev, ...prev,
firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point', firstname: loc.contactname || loc.contactperson || hubName,
contactno: String(loc.contactno || loc.phone || prev.contactno || '') contactno: contactPhone || prev.contactno,
.replace(/\D/g, '')
.slice(0, 10),
address: loc.address || '', address: loc.address || '',
landmark: loc.landmark || '', landmark: loc.landmark || '',
suburb: loc.locationname || loc.city || '', suburb: loc.locationname || loc.city || '',
@@ -478,55 +602,74 @@ export default function CreateOrder() {
// Select customer from modal // Select customer from modal
const handlePickCustomer = (customer) => { const handlePickCustomer = (customer) => {
// GET /admin/customers already returns the whole address — doorno, address, if (!customer) return;
// 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.
const digits = (v) => String(v ?? '').replace(/\D/g, ''); const digits = (v) => String(v ?? '').replace(/\D/g, '');
const text = (v) => String(v ?? '').trim(); const text = (v) => String(v ?? '').trim();
// Door number and landmark are separate columns but there is one address // Support multiple field schemas (Doormile appcustomers, legacy Nearle customers, or saved orders)
// line on this form, so fold them in rather than lose them. const rawAddress = text(
const street = [text(customer.doorno), text(customer.address)].filter(Boolean).join(', '); customer.deliveryaddress ||
const lat = customer.latitude; customer.address ||
const lng = customer.longitude; customer.formatted_address ||
const hasCoords = Number.isFinite(Number(lat)) && Number.isFinite(Number(lng)) && Number(lat) !== 0 && Number(lng) !== 0; 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) => ({ setDropCust((prev) => ({
...prev, ...prev,
firstname: customer.name || prev.firstname, firstname: customer.name || customer.firstname || customer.customer_name || prev.firstname,
contactno: digits(customer.phone) ? digits(customer.phone).slice(0, 10) : prev.contactno, contactno: digits(customer.phone || customer.contactno || customer.customer_phone)
// Every field falls back to what is already typed. A contact with a name ? digits(customer.phone || customer.contactno || customer.customer_phone).slice(0, 10)
// and no address must never blank an address the operator has entered — : prev.contactno,
// silently erasing their work is a worse bug than not filling it.
address: street || prev.address, address: street || prev.address,
landmark: text(customer.landmark) || prev.landmark, landmark: landmark || prev.landmark,
suburb: text(customer.suburb) || prev.suburb, suburb: suburb || prev.suburb,
city: text(customer.city) || prev.city, city: city || prev.city,
postcode: text(customer.postcode) || prev.postcode, postcode: postcode || prev.postcode,
latitude: hasCoords ? lat : prev.latitude, latitude: hasCoords ? Number(lat) : prev.latitude,
longitude: hasCoords ? lng : prev.longitude longitude: hasCoords ? Number(lng) : prev.longitude
})); }));
setIsCustomerModalOpen(false); setIsCustomerModalOpen(false);
// Say what actually happened. A contact with no address on file leaves the const name = customer.name || customer.firstname || customer.phone || 'Contact';
// 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';
if (street && hasCoords) { if (street && hasCoords) {
OpenToast(`${name} — contact and address filled`, 'success', 2500); OpenToast(`${name} — contact and address filled`, 'success', 2500);
} else if (street) { } 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 { } 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); OpenToast('Enter pickup address', 'warning', 2500);
return; return;
} }
// Pickup coordinates were never checked here. That was survivable only // Pickup coordinates check
// 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.
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) { if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
OpenToast( OpenToast(
isCustomerPickup isCustomerPickup
@@ -615,20 +754,11 @@ export default function CreateOrder() {
finalprice, finalprice,
notes: notes || '', notes: notes || '',
parcels, 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({ ...buildFlowFields({
flow: draftFlow.flow, flow: draftFlow.flow,
servicingHubId: servicingHubId:
draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null, draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null,
tenantLocationId: selectedLocationId, 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 pickupAnchor: draftFlow.originAnchor
}) })
}; };
@@ -637,6 +767,10 @@ export default function CreateOrder() {
try { try {
const res = await createExpressBooking(payload); const res = await createExpressBooking(payload);
if (res?.success) { if (res?.success) {
// Save customer details for future orders if selected
if (saveForLater && dropCust.contactno && dropCust.firstname) {
saveCustomerRecord(dropCust);
}
saveRecentAddress({ saveRecentAddress({
formatted_address: dropCust.address, formatted_address: dropCust.address,
name: dropCust.firstname, name: dropCust.firstname,
@@ -648,6 +782,7 @@ export default function CreateOrder() {
}); });
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] }); queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
queryClient.invalidateQueries({ queryKey: ['ordersBulk'] }); queryClient.invalidateQueries({ queryKey: ['ordersBulk'] });
queryClient.invalidateQueries({ queryKey: ['adminCustomers'] });
OpenToast('Order created successfully', 'success', 2500); OpenToast('Order created successfully', 'success', 2500);
navigate('/doormile/orders'); navigate('/doormile/orders');
} else { } else {
@@ -1086,11 +1221,66 @@ export default function CreateOrder() {
type="text" type="text"
placeholder="Near landmark" placeholder="Near landmark"
value={dropCust.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" 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"
/> />
</div> </div>
</div> </div>
{/* Save contact for future orders option */}
{dropCust.contactno && dropCust.contactno.length >= 8 && dropCust.firstname.trim() && (
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 border border-slate-200 mt-1 transition-all animate-in fade-in duration-150">
<label className="flex items-center gap-2.5 cursor-pointer select-none min-w-0 pr-3">
<input
type="checkbox"
id="save-customer-later"
checked={saveForLater}
onChange={(e) => {
setSaveForLater(e.target.checked);
if (e.target.checked && !isSavedNow) {
saveCustomerRecord(dropCust);
}
}}
className="w-4 h-4 rounded text-black border-slate-300 focus:ring-black cursor-pointer shrink-0"
/>
<div className="min-w-0">
<span className="text-xs font-semibold text-slate-800 flex items-center gap-1.5">
<span>Save contact for future orders</span>
<span className="text-[10px] text-emerald-700 bg-emerald-100/80 font-bold px-1.5 py-0.2 rounded">
Autofill
</span>
</span>
<p className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">
Saves name, phone & address to quickly autofill on upcoming orders
</p>
</div>
</label>
<button
type="button"
onClick={() => saveCustomerRecord(dropCust)}
className={`h-7 px-3 text-[11px] font-semibold rounded-lg transition-all flex items-center gap-1.5 cursor-pointer shrink-0 ${
isSavedNow
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-2xs'
: 'bg-black text-white hover:bg-neutral-800 shadow-2xs active:scale-[0.98]'
}`}
>
{isSavedNow ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-600" />
<span>Saved</span>
</>
) : (
<>
<Bookmark className="w-3.5 h-3.5" />
<span>Save Contact</span>
</>
)}
</button>
</div>
)}
</div> </div>
{/* Schedule & Packages Card */} {/* Schedule & Packages Card */}
@@ -1571,26 +1761,54 @@ export default function CreateOrder() {
No customers found matching &quot;{searchCustQuery}&quot; No customers found matching &quot;{searchCustQuery}&quot;
</div> </div>
) : ( ) : (
filteredCustomers.map((cust) => ( filteredCustomers.map((cust) => {
<div const addr = cust.deliveryaddress || cust.address || cust.formatted_address || cust.location;
key={cust.appcustomerid || cust.id || cust.phone} const hasPin = Boolean(cust.latitude && cust.longitude);
className="p-3 hover:bg-slate-50 rounded-lg flex items-center justify-between gap-3 transition-colors" return (
> <div
<div> key={cust.appcustomerid || cust.id || cust.phone || Math.random()}
<div className="text-xs font-bold text-slate-900">{cust.name || 'Unnamed Contact'}</div> className="p-3 hover:bg-slate-50 rounded-lg flex items-center justify-between gap-3 transition-colors"
<div className="text-[11px] text-slate-500 font-mono mt-0.5">{cust.phone || '—'}</div>
{cust.email && <div className="text-[10px] text-slate-400">{cust.email}</div>}
</div>
<button
type="button"
onClick={() => handlePickCustomer(cust)}
className="h-7 px-3 bg-slate-900 hover:bg-black text-white text-[11px] font-semibold rounded-md transition-colors cursor-pointer shrink-0"
> >
Select <div className="min-w-0 flex-1">
</button> <div className="flex items-center gap-2 flex-wrap">
</div> <span className="text-xs font-bold text-slate-900 truncate">
)) {cust.name || cust.firstname || 'Unnamed Contact'}
</span>
<span className="text-[11px] text-slate-500 font-mono">
({cust.phone || cust.contactno || '—'})
</span>
{cust.isSavedForLater && (
<span className="px-1.5 py-0.2 bg-violet-100 text-violet-700 text-[9px] font-bold rounded">
Saved
</span>
)}
{hasPin && (
<span className="px-1.5 py-0.2 bg-emerald-100 text-emerald-700 text-[9px] font-bold rounded">
📍 Pinned
</span>
)}
</div>
{addr ? (
<div className="text-[11px] text-slate-600 mt-1 line-clamp-1 leading-snug">
{addr}
{cust.city && <span className="text-slate-400 font-normal"> · {cust.city}</span>}
{cust.postcode && <span className="text-slate-400 font-mono"> - {cust.postcode}</span>}
</div>
) : (
<div className="text-[10px] text-slate-400 mt-0.5 italic">No address on file</div>
)}
</div>
<button
type="button"
onClick={() => handlePickCustomer(cust)}
className="h-7 px-3 bg-slate-900 hover:bg-black text-white text-[11px] font-semibold rounded-md transition-colors cursor-pointer shrink-0 ml-2"
>
Select
</button>
</div>
);
})
)} )}
</div> </div>