Latest Order
+ {latestOrder ? ( + <> +Create an order to populate the route and assistant.
+ )} +- Schedule a food or beverage delivery. -
- - ++ Submit an order so the site can calculate a route and the assistant + can answer questions about it. +
+ + ++ After you submit an order, the home page uses the saved destination + to calculate a route and the delivery assistant uses that same order + for status and ETA questions. +
+ + {submissionState.order && ( ++ Status: {formatOrderStatus(submissionState.order.status)} +
++ Assigned Bot: {submissionState.order.assignedBotId || "Pending"} +
++ Items: {summarizeItems(submissionState.order.items)} +
++ Source: {submissionState.source === "api" ? "Order Service" : "Local preview"} +
+ + {submissionState.warning && ( +{submissionState.warning}
+ )} + + + View Fleet Map + +- Fast autonomous food and beverage delivery throughout Spokane. + View the fleet, draw the OSRM route, and ask the delivery assistant + about the latest order.
- - Order Now - -Create an order to populate the route and assistant.
+ )} +{routeState.message}
+ > + ) : ( +{routeState.message}
+ )} +{text}
+Fleet map
-{bot.botId}
@@ -385,7 +566,7 @@ function BotCard({ bot, isDemoData }) { color: statusColor.text }} > - {bot.status || "Unknown"} + {formatStatus(effectiveStatus)}Assigned to latest order
} {isDemoData &&Demo preview
} ) @@ -434,8 +616,8 @@ function Detail({ label, value }) { function getFleetStats(botList) { const total = botList.length - const available = botList.filter((bot) => bot.status === "Available").length - const onDelivery = botList.filter((bot) => bot.status === "OnDelivery").length + const available = botList.filter((bot) => getEffectiveStatus(bot) === "Available").length + const onDelivery = botList.filter((bot) => getEffectiveStatus(bot) === "OnDelivery").length const averageBattery = total === 0 ? 0 @@ -466,6 +648,10 @@ function getStockSummary(stock = []) { } function formatStatus(status) { + if (!status) { + return "Unknown" + } + if (status === "OnDelivery") { return "On delivery" } @@ -473,6 +659,18 @@ function formatStatus(status) { return status } +function getEffectiveStatus(bot) { + if (bot?.status) { + return bot.status + } + + if (bot?.activeOrderId || Number(bot?.queuedOrderCount || 0) > 0) { + return "OnDelivery" + } + + return "Available" +} + function getStatusColor(status) { if (status === "Available") { return { @@ -508,34 +706,40 @@ const styles = { backgroundColor: "#0f172a", color: "#f8fafc", padding: "2rem", - fontFamily: "Arial", display: "flex", flexDirection: "column", alignItems: "center" }, - heroCard: { backgroundColor: "#1f2937", padding: "3rem", borderRadius: "8px", - textAlign: "center", - maxWidth: "700px", + textAlign: "left", + maxWidth: "1100px", width: "100%", - marginTop: "3rem", - border: "1px solid #334155" + marginTop: "1rem", + border: "1px solid #334155", + boxSizing: "border-box" }, - title: { - fontSize: "clamp(2.5rem, 7vw, 4rem)", - marginBottom: "1rem" + fontSize: "clamp(2.5rem, 6vw, 4rem)", + marginBottom: "1rem", + color: "#f8fafc", + lineHeight: 1.1, + maxWidth: "20ch" }, - subtitle: { color: "#cbd5e1", fontSize: "1.1rem", - marginBottom: "2rem" + marginBottom: "1.5rem", + maxWidth: "44rem" + }, + heroActions: { + display: "flex", + flexWrap: "wrap", + gap: "1rem", + alignItems: "center" }, - button: { display: "inline-block", backgroundColor: "#2563eb", @@ -545,30 +749,20 @@ const styles = { borderRadius: "8px", fontWeight: "bold" }, - - cards: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", - gap: "1.5rem", - width: "100%", - maxWidth: "1100px", - marginTop: "3rem" - }, - - card: { - backgroundColor: "#1f2937", - padding: "2rem", + heroNote: { + color: "#cbd5e1", + backgroundColor: "#111827", + border: "1px solid #334155", borderRadius: "8px", - border: "1px solid #334155" + padding: "0.65rem 0.85rem", + fontSize: "0.9rem" }, - fleetSection: { width: "100%", maxWidth: "1100px", - marginTop: "3rem", + marginTop: "1.7rem", paddingBottom: "2rem" }, - sectionHeader: { display: "flex", justifyContent: "space-between", @@ -578,7 +772,6 @@ const styles = { textAlign: "left", flexWrap: "wrap" }, - kicker: { color: "#38bdf8", fontSize: "0.85rem", @@ -587,13 +780,11 @@ const styles = { marginBottom: "0.35rem", textTransform: "uppercase" }, - sectionTitle: { color: "#f8fafc", fontSize: "1.75rem", margin: 0 }, - syncStatus: { color: "#cbd5e1", backgroundColor: "#111827", @@ -602,7 +793,6 @@ const styles = { padding: "0.65rem 0.85rem", fontSize: "0.9rem" }, - notice: { color: "#fde68a", backgroundColor: "#422006", @@ -612,14 +802,12 @@ const styles = { textAlign: "left", marginBottom: "1rem" }, - statGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "1rem", marginBottom: "1rem" }, - metric: { backgroundColor: "#111827", border: "1px solid #334155", @@ -627,13 +815,11 @@ const styles = { padding: "1rem", textAlign: "left" }, - metricLabel: { color: "#94a3b8", display: "block", fontSize: "0.85rem" }, - metricValue: { color: "#f8fafc", display: "block", @@ -641,7 +827,46 @@ const styles = { lineHeight: 1.2, marginTop: "0.3rem" }, - + routeGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", + gap: "1rem", + marginBottom: "1rem" + }, + routeCard: { + backgroundColor: "#1f2937", + border: "1px solid #334155", + borderRadius: "8px", + padding: "1.25rem", + textAlign: "left" + }, + routeTitle: { + margin: "0 0 1rem", + fontSize: "1.2rem" + }, + routeMetric: { + display: "grid", + gap: "0.15rem", + marginBottom: "0.85rem" + }, + routeMetricLabel: { + color: "#94a3b8", + fontSize: "0.82rem", + textTransform: "uppercase", + letterSpacing: "0.08em" + }, + routeMetricValue: { + color: "#f8fafc", + fontSize: "1rem" + }, + emptyText: { + color: "#cbd5e1", + lineHeight: 1.5 + }, + routeMessage: { + marginTop: "0.9rem", + color: "#cbd5e1" + }, mapPanel: { backgroundColor: "#f8fafc", border: "1px solid #cbd5e1", @@ -651,7 +876,6 @@ const styles = { padding: "1rem", textAlign: "left" }, - mapHeader: { display: "flex", justifyContent: "space-between", @@ -660,14 +884,12 @@ const styles = { marginBottom: "1rem", flexWrap: "wrap" }, - mapTitle: { color: "#0f172a", fontSize: "1.35rem", lineHeight: 1.2, margin: 0 }, - mapCount: { color: "#475569", backgroundColor: "#e2e8f0", @@ -677,14 +899,12 @@ const styles = { fontSize: "0.82rem", fontWeight: "bold" }, - mapShell: { display: "flex", flexDirection: "column", gap: "1rem", alignItems: "stretch" }, - mapCanvas: { height: "430px", minHeight: "360px", @@ -693,7 +913,6 @@ const styles = { border: "1px solid #bfdbfe", backgroundColor: "#e2e8f0" }, - mapLegend: { display: "flex", flexWrap: "wrap", @@ -701,10 +920,8 @@ const styles = { backgroundColor: "#f1f5f9", border: "1px solid #cbd5e1", borderRadius: "8px", - padding: "1rem", - minHeight: "auto" + padding: "1rem" }, - legendItem: { display: "flex", alignItems: "center", @@ -713,26 +930,22 @@ const styles = { fontSize: "0.9rem", fontWeight: "bold" }, - legendDot: { width: "0.8rem", height: "0.8rem", borderRadius: "999px", flex: "0 0 auto" }, - mapFootnote: { color: "#64748b", fontSize: "0.85rem", marginTop: "0.85rem" }, - botGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "1rem" }, - botCard: { backgroundColor: "#f8fafc", border: "1px solid #cbd5e1", @@ -744,7 +957,9 @@ const styles = { boxSizing: "border-box", position: "relative" }, - + assignedBotCard: { + borderColor: "#fb923c" + }, botHeader: { display: "flex", justifyContent: "space-between", @@ -752,18 +967,15 @@ const styles = { gap: "0.75rem", marginBottom: "1rem" }, - botId: { fontSize: "1.15rem", fontWeight: "bold", marginBottom: "0.1rem" }, - botModel: { color: "#64748b", fontSize: "0.9rem" }, - statusBadge: { borderRadius: "999px", fontSize: "0.8rem", @@ -771,14 +983,12 @@ const styles = { padding: "0.35rem 0.65rem", whiteSpace: "nowrap" }, - batteryRow: { display: "flex", justifyContent: "space-between", color: "#334155", marginBottom: "0.45rem" }, - batteryTrack: { height: "10px", backgroundColor: "#e2e8f0", @@ -786,44 +996,44 @@ const styles = { overflow: "hidden", marginBottom: "1rem" }, - batteryFill: { height: "100%", backgroundColor: "#22c55e", borderRadius: "999px" }, - botDetails: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.85rem", - marginBottom: "1rem" + marginBottom: "1.5rem" }, - detail: { minWidth: 0 }, - detailLabel: { color: "#64748b", display: "block", fontSize: "0.78rem", marginBottom: "0.15rem" }, - detailValue: { color: "#0f172a", display: "block", fontSize: "0.92rem", overflowWrap: "anywhere" }, - location: { color: "#475569", fontFamily: "Consolas, monospace", fontSize: "0.85rem" }, - + assignedLabel: { + display: "inline-block", + marginTop: "0.4rem", + color: "#ea580c", + fontSize: "0.8rem", + fontWeight: "bold" + }, demoLabel: { position: "absolute", right: "1rem", diff --git a/frontend/customer-webapp/src/ui.contracts.test.js b/frontend/customer-webapp/src/ui.contracts.test.js new file mode 100644 index 0000000..7fac4ad --- /dev/null +++ b/frontend/customer-webapp/src/ui.contracts.test.js @@ -0,0 +1,40 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import { assistantStyles } from "./lib/assistantStyles.js" + +test("assistant panel stays above the floating launcher and page content", () => { + assert.ok(assistantStyles.panel.zIndex > assistantStyles.fab.zIndex) +}) + +test("home view keeps the assigned label inline instead of overlaying card content", () => { + const homeSource = fs.readFileSync( + path.resolve("src/pages/Home.jsx"), + "utf8" + ) + + assert.match(homeSource, /assignedLabel:\s*\{[^}]*display:\s*"inline-block"/) + assert.doesNotMatch(homeSource, /assignedLabel:\s*\{[^}]*position:/) +}) + +test("home route cards and map canvas keep readable default sizing", () => { + const homeSource = fs.readFileSync( + path.resolve("src/pages/Home.jsx"), + "utf8" + ) + + assert.match(homeSource, /routeCard:\s*\{[^}]*borderRadius:\s*"8px"/) + assert.match(homeSource, /mapCanvas:\s*\{[^}]*minHeight:\s*"360px"/) +}) + +test("home keeps the map section and route summary labels in the page source", () => { + const homeSource = fs.readFileSync( + path.resolve("src/pages/Home.jsx"), + "utf8" + ) + + assert.match(homeSource, /aria-label="Robot location map"/) + assert.match(homeSource, /OSRM Route/) + assert.match(homeSource, /Latest Order/) +}) diff --git a/frontend/customer-webapp/vite.config.js b/frontend/customer-webapp/vite.config.js index 6064c37..81fbb1c 100644 --- a/frontend/customer-webapp/vite.config.js +++ b/frontend/customer-webapp/vite.config.js @@ -1,15 +1,61 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' -export default defineConfig({ - plugins: [react()], - base: '/', - server: { - proxy: { - '/api/simulator': { - target: 'http://localhost:5099', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/simulator/, '') +function trimTrailingSlash(value) { + return typeof value === "string" ? value.replace(/\/+$/, "") : "" +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function createProxyConfig(prefix, target, fallbackTarget) { + const resolvedTarget = trimTrailingSlash(target) || fallbackTarget + + if (!resolvedTarget) { + return undefined + } + + const parsed = new URL(resolvedTarget) + const origin = `${parsed.protocol}//${parsed.host}` + const basePath = trimTrailingSlash(parsed.pathname) + const prefixPattern = new RegExp(`^${escapeRegExp(prefix)}`) + + return { + target: origin, + changeOrigin: true, + secure: false, + rewrite: (path) => `${basePath}${path.replace(prefixPattern, "")}` + } +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, fileURLToPath(new URL('.', import.meta.url)), "") + const simulatorTarget = trimTrailingSlash(env.VITE_SIMULATOR_API_BASE) + const orderServiceTarget = trimTrailingSlash( + env.VITE_ORDER_SERVICE_URL || env.VITE_ORDER_SERVICE_API_BASE + ) + const agentTarget = trimTrailingSlash(env.VITE_AGENT_API_URL) + + return { + plugins: [react()], + base: '/', + server: { + proxy: { + '/api/simulator': createProxyConfig( + '/api/simulator', + simulatorTarget, + 'http://localhost:5099' + ), + '/api/order-service': createProxyConfig( + '/api/order-service', + orderServiceTarget + ), + '/api/agent': createProxyConfig( + '/api/agent', + agentTarget + ) } } }