// pages.jsx — non-kiosk page components.
// MarkdownPage fetches from GitHub raw, parses via marked CDN,
// sanitizes via DOMPurify CDN before injecting.
// DashboardPage renders KPI cards + chart + activity feed + Liquid-style chat bar.
const { useState: usePageState, useEffect: usePageEffect, useRef: usePageRef } = React;
const RAW_BASE = "https://raw.githubusercontent.com/carrickcheah/ai-feedme/main/";
const CHAT_API_URL =
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
? "http://localhost:8002/api/chat/sync"
: "/api/chat/sync";
function renderMarkdownSafely(md) {
if (!window.marked) return { __html: "" };
const raw = window.marked.parse(md);
const clean = window.DOMPurify ? window.DOMPurify.sanitize(raw) : raw;
return { __html: clean };
}
// ─── SVG page (architecture diagrams etc) ──────────────────────
// Cache-bust the SVG URL with a per-page-load timestamp so iterative
// diagram edits surface immediately without forcing a hard-reload.
function SvgPage({ src, title }) {
const cb = usePageRef(Date.now()).current;
const url = `${RAW_BASE}${src}?cb=${cb}`;
return (
);
}
// ─── Markdown page ──────────────────────────────────────────────
function MarkdownPage({ file, title }) {
const [md, setMd] = usePageState("");
const [error, setError] = usePageState(null);
usePageEffect(() => {
if (!file) return;
setMd("");
setError(null);
fetch(RAW_BASE + file)
.then((r) => r.ok ? r.text() : Promise.reject(new Error("HTTP " + r.status)))
.then(setMd)
.catch((e) => setError(e.message));
}, [file]);
return (
{title &&
{title} }
{error ? (
Could not load {file}: {error}
) : md ? (
) : (
Loading {file}…
)}
);
}
// ─── Liquid-style sticky chat bar with pop-up thread ────────────
function DashboardChatBar({ agentLabel }) {
const [input, setInput] = usePageState("");
const [messages, setMessages] = usePageState([]);
const [loading, setLoading] = usePageState(false);
const [sessionId, setSessionId] = usePageState(null);
const [open, setOpen] = usePageState(false);
const threadRef = usePageRef(null);
const inputRef = usePageRef(null);
// Auto-scroll thread on new content.
usePageEffect(() => {
if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
}, [messages, loading]);
// Refocus the input after the LLM response lands, so the user can
// immediately type the next message without re-clicking.
usePageEffect(() => {
if (!loading && open && inputRef.current) {
const id = setTimeout(() => inputRef.current && inputRef.current.focus(), 50);
return () => clearTimeout(id);
}
}, [loading, open]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
setInput("");
setMessages((m) => [...m, { role: "user", text }]);
setOpen(true);
setLoading(true);
try {
const res = await fetch(CHAT_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, session_id: sessionId, channel: "web" }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
if (data.session_id) setSessionId(data.session_id);
setMessages((m) => [...m, { role: "assistant", text: data.output || "(no response)" }]);
} catch (err) {
setMessages((m) => [...m, { role: "assistant", text: "Connection error: " + (err.message || err) }]);
} finally {
setLoading(false);
}
};
const onKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
return (
{open && (messages.length > 0 || loading) && (
Chat with {agentLabel}
setOpen(false)}
>
{ setOpen(false); setMessages([]); setSessionId(null); }}
>×
{messages.map((m, i) => (
{m.text}
))}
{loading &&
…
}
)}
);
}
// ─── KPI card grid + chart + activity feed dashboard ────────────
// Small inline icon set — stroke-based, scale to currentColor.
const KPI_ICONS = {
ticket: ,
clock: ,
check: ,
queue: ,
pkg: ,
alert: ,
truck: ,
off: ,
};
function TrendDelta({ trend }) {
if (!trend) return null;
const dir = trend.dir || "flat";
const arrow = dir === "up" ? "↑" : dir === "down" ? "↓" : "→";
const good = (trend.good !== undefined) ? trend.good : (dir === "up");
const cls = "fm-kpi-trend " + (dir === "flat" ? "flat" : (good ? "good" : "bad"));
return (
{arrow}
{trend.delta}
{trend.vs && {trend.vs} }
);
}
function KPI({ icon, value, label, tone, trend }) {
return (
{icon && KPI_ICONS[icon] && (
{KPI_ICONS[icon]}
)}
);
}
const CHART_PX = 130; // max bar height in pixels — keeps bars visible regardless of container layout
function barTone(v, scale) {
// For 0–100 scales: <25 = red, 25–50 = amber, >50 = healthy. For other scales,
// fall back to "neutral" so the chart stays orange-brand by default.
if (!scale || scale !== "percent") return "neutral";
if (v < 25) return "low";
if (v < 50) return "mid";
return "ok";
}
function BarChart({ title, values, labels, scale }) {
const max = Math.max(...values, 1);
return (
{title}
{values.map((v, i) => {
const tone = barTone(v, scale);
const px = Math.max(4, Math.round((v / max) * CHART_PX));
return (
{v}{scale === "percent" ? "%" : ""}
{labels[i]}
);
})}
);
}
function ActivityFeed({ title, rows }) {
return (
{title}
{rows.map((r, i) => (
{r.time}
{r.status && (
{r.status}
)}
{r.id}
{r.text}
))}
);
}
const ADMIN_API_BASE =
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
? "http://localhost:8002/api/admin"
: "/api/admin";
/**
* DashboardPage now accepts a `statsUrl` to fetch live data from. The
* static props (agent, tagline, agentLabel) come from the parent; KPIs,
* chart, activity, and status are server-fed and refresh every 30s.
*/
function DashboardPage({ agent, tagline, agentLabel, statsUrl, initial }) {
const [data, setData] = usePageState(initial || {});
const [loading, setLoading] = usePageState(true);
const [error, setError] = usePageState(null);
const load = () => {
if (!statsUrl) return;
fetch(ADMIN_API_BASE + statsUrl)
.then((r) => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
.then((d) => { setData(d); setError(null); setLoading(false); })
.catch((e) => { setError(e.message); setLoading(false); });
};
usePageEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [statsUrl]);
const kpis = data.kpis || initial?.kpis || [];
const chart = data.chart || initial?.chart;
const activity = data.activity || initial?.activity || [];
const status = data.status || initial?.status;
return (
{agent}
{tagline}
{error &&
live data unavailable: {error} — showing seed values
}
{status && (
{status.label}
{status.since}
)}
{kpis.map((k, i) => )}
{chart &&
}
);
}
// ─── Mock data for each agent dashboard ─────────────────────────
const KITCHEN_DATA = {
agent: "Kitchen Agent (Internal Support)",
agentLabel: "Kitchen Agent",
tagline: "Event-driven · triggers on order.created · MCPs: pos, kitchen-display, supplier",
status: { label: "Live", since: "updated 2m ago" },
kpis: [
{ icon: "ticket", value: "24", label: "tickets today", trend: { delta: "+12%", dir: "up", vs: "vs yesterday" } },
{ icon: "clock", value: "7m 22s", label: "avg cook time", trend: { delta: "-30s", dir: "down", vs: "vs yesterday", good: true } },
{ icon: "check", value: "95%", label: "on-time rate", trend: { delta: "+2pp", dir: "up", vs: "vs yesterday" } },
{ icon: "queue", value: "3", label: "in queue", tone: "warn", trend: { delta: "near par", dir: "flat" } },
],
chart: {
title: "Tickets per hour",
labels: ["9a","10a","11a","12p","1p","2p","3p","4p","5p","6p"],
values: [1, 2, 3, 4, 6, 8, 6, 3, 2, 1],
},
activity: [
{ time: "14:32", id: "ORD-9871", text: "Mango Iceyoo × 2", status: "SENT" },
{ time: "14:28", id: "ORD-9870", text: "(Any 2) YooYoo Saver", status: "COOKING" },
{ time: "14:25", id: "ORD-9869", text: "Korean Chicken Wings (6 pcs)", status: "COOKING" },
{ time: "14:21", id: "ORD-9868", text: "Oreo Cheesecake Bingsu", status: "READY" },
{ time: "14:17", id: "ORD-9867", text: "Mango Iceyoo × 1", status: "DONE" },
{ time: "14:11", id: "ORD-9866", text: "Tutti Frutti Ice Blended", status: "DONE" },
],
};
const INVENTORY_DATA = {
agent: "Inventory Agent (Internal Support)",
agentLabel: "Inventory Agent",
tagline: "Event-driven · triggers on ingredient.consumed · MCP: supplier",
status: { label: "Live", since: "updated 1m ago" },
kpis: [
{ icon: "pkg", value: "47", label: "ingredients", trend: { delta: "+2", dir: "up", vs: "this week" } },
{ icon: "alert", value: "3", label: "below par", tone: "warn", trend: { delta: "+1", dir: "up", vs: "since 1pm", good: false } },
{ icon: "truck", value: "2", label: "reorders today", trend: { delta: "on track", dir: "flat" } },
{ icon: "off", value: "5", label: "items 86'd", tone: "warn", trend: { delta: "+2", dir: "up", vs: "vs yesterday", good: false } },
],
chart: {
title: "Stock levels (% of par)",
scale: "percent",
labels: ["Mango","Milk","Oreo","Ice","Cream","Chicken","Sugar","Strawberry","Coffee","Mint"],
values: [95, 78, 32, 88, 12, 65, 22, 82, 58, 42],
},
activity: [
{ time: "14:30", id: "ING-204", text: "Cream cheese: 0.5kg / 3kg par", status: "STOCK.LOW" },
{ time: "14:25", id: "PO-1142", text: "Mango syrup × 5kg · supplier B", status: "REORDER" },
{ time: "13:50", id: "ING-118", text: "Oreo crumbs: 0.2kg / 1kg par", status: "STOCK.LOW" },
{ time: "13:45", id: "MENU-22", text: "Oreo Cheesecake Bingsu disabled", status: "AUTO-86" },
{ time: "13:40", id: "PO-1141", text: "Chicken wings × 10kg · supplier A", status: "REORDER" },
{ time: "13:22", id: "ING-067", text: "Mint leaves: 80g / 200g par", status: "STOCK.LOW" },
],
};
function KitchenAgentPage() {
return ;
}
function InventoryAgentPage() {
return ;
}
// ─── Placeholder for items without a backing doc ────────────────
function ComingSoonPage({ what }) {
return (
{what}
This section isn't part of the interview prototype scope. The build artifacts are in the repo:
For interview demo, the eval loop in bun run eval stands in for full CI/CD — it's the test gate that would block a merge.
);
}
Object.assign(window, {
MarkdownPage,
SvgPage,
KitchenAgentPage,
InventoryAgentPage,
ComingSoonPage,
DashboardPage,
DashboardChatBar,
});