diff --git a/src/layout/MainLayout/AppTopNav.js b/src/layout/MainLayout/AppTopNav.js index 6ed6a4c..327c9ec 100644 --- a/src/layout/MainLayout/AppTopNav.js +++ b/src/layout/MainLayout/AppTopNav.js @@ -34,6 +34,7 @@ import { clearFcmToken } from 'store/reducers/fcmSlice'; import { logoutUser } from 'store/reducers/loginUserSlice'; import { performSessionLogout } from 'utils/session'; import { DT } from 'themes/dt/tokens'; +import DoormileAITrigger from 'pages/nearle/assistant/DoormileAI'; // doormile-logo.png is a white asset; recolour to brand red for this // light TopNav surface (same trick login.js uses for its white-card logo). @@ -130,6 +131,8 @@ const AppTopNav = () => { ]} /> + + } label="Notifications"> } /> diff --git a/src/menu-items/nearle.js b/src/menu-items/nearle.js index 3523a75..5d58751 100644 --- a/src/menu-items/nearle.js +++ b/src/menu-items/nearle.js @@ -32,8 +32,7 @@ import { TruckOutlined, WarningOutlined, RadarChartOutlined, - RiseOutlined, - RobotOutlined + RiseOutlined } from '@ant-design/icons'; // icons @@ -67,8 +66,7 @@ const icons = { TruckOutlined, WarningOutlined, RadarChartOutlined, - RiseOutlined, - RobotOutlined + RiseOutlined }; // ==============================|| MENU ITEMS - SUPPORT ||============================== // @@ -128,13 +126,6 @@ const nearle = { url: '/doormile/customers', icon: icons.ContactsOutlined }, - { - id: 'assistant', - title: , - type: 'item', - url: '/doormile/assistant', - icon: icons.RobotOutlined - }, { id: 'fleetops', title: , diff --git a/src/pages/nearle/assistant/DoormileAI.css b/src/pages/nearle/assistant/DoormileAI.css new file mode 100644 index 0000000..ac758f9 --- /dev/null +++ b/src/pages/nearle/assistant/DoormileAI.css @@ -0,0 +1,776 @@ +/* ========================================================================== + Doormile AI — Operations Copilot + -------------------------------------------------------------------------- + Styling for the right-side slide-over assistant. Follows the same + convention as OrdersRedesign.css / Dispatch.css (a page-scoped stylesheet + imported by its own component) rather than inline styles, so the panel's + hover / focus / media-query / reduced-motion states are all expressible. + + Every value below resolves to a design token (`--color-*`, `--spacing-*`, + `--radius-*`) or to a local `--dai-*` token defined once here. The local + tokens exist because the AI surface needs a few values the shared DT set + doesn't carry (the AI accent, the scrim, the panel elevation). + ========================================================================== */ + +.dai-root { + /* Brand accent — resolves to the app's accent token, which is black + (root CLAUDE.md §6.2: "the brand is black", one accent only). Send + button, active states and the trigger all read from this single + variable, so switching the assistant to Doormile red is a one-line + change here rather than a hunt through the components. */ + --dai-accent: var(--color-accent, #0f172a); + --dai-accent-contrast: #ffffff; + + /* AI identity accent — used ONLY on the spark/orb marks so the assistant + reads as an AI surface without turning the panel into a purple product. */ + --dai-ai-from: #6366f1; + --dai-ai-to: #8b5cf6; + --dai-ai-glow: rgba(99, 102, 241, 0.18); + + --dai-text: #0f172a; + --dai-text-secondary: #64748b; + --dai-text-muted: #94a3b8; + --dai-surface: #ffffff; + --dai-surface-alt: #f8fafc; + --dai-surface-hover: #f1f5f9; + --dai-border: rgba(15, 23, 42, 0.08); + --dai-border-strong: rgba(15, 23, 42, 0.14); + --dai-scrim: rgba(15, 23, 42, 0.08); + --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1); + --dai-live: #10b981; + + --dai-panel-width: 428px; + --dai-inset: 12px; + --dai-duration: 240ms; + --dai-ease: cubic-bezier(0.16, 1, 0.3, 1); +} + +/* -------------------------------------------------------------------------- + Scrim — deliberately light. The dashboard underneath must stay readable; + this is a layering cue, not a modal blackout. + -------------------------------------------------------------------------- */ +.dai-scrim { + position: fixed; + inset: 0; + z-index: 1300; + background: var(--dai-scrim); + opacity: 0; + transition: opacity var(--dai-duration) var(--dai-ease); +} + +.dai-scrim[data-open='true'] { + opacity: 1; +} + +/* -------------------------------------------------------------------------- + Panel + -------------------------------------------------------------------------- */ +.dai-panel { + position: fixed; + top: var(--dai-inset); + right: var(--dai-inset); + bottom: var(--dai-inset); + z-index: 1301; + width: var(--dai-panel-width); + max-width: calc(100vw - (var(--dai-inset) * 2)); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--dai-surface); + border: 1px solid var(--dai-border); + border-radius: 16px; + box-shadow: var(--dai-shadow); + transform: translateX(calc(100% + var(--dai-inset) * 2)); + opacity: 0; + transition: + transform var(--dai-duration) var(--dai-ease), + opacity var(--dai-duration) var(--dai-ease); +} + +.dai-panel[data-open='true'] { + transform: translateX(0); + opacity: 1; +} + +.dai-panel:focus { + outline: none; +} + +/* -------------------------------------------------------------------------- + Header + -------------------------------------------------------------------------- */ +.dai-root .dai-header { + flex: 0 0 auto; + padding: 14px 12px 12px 14px; + border-bottom: 1px solid var(--dai-border); +} + +.dai-root .dai-title { + font-size: 15px; + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.01em; + color: var(--dai-text); +} + +.dai-root .dai-subtitle { + font-size: 12px; + line-height: 1.3; + color: var(--dai-text-muted); +} + +/* The AI mark. A soft gradient orb — not a robot face. */ +.dai-root .dai-spark { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border-radius: 999px; + color: #ffffff; + background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to)); + box-shadow: 0 0 0 3px var(--dai-ai-glow); +} + +.dai-root .dai-spark[data-size='sm'] { + width: 22px; + height: 22px; +} + +.dai-root .dai-spark[data-size='md'] { + width: 30px; + height: 30px; +} + +.dai-root .dai-spark[data-size='lg'] { + width: 44px; + height: 44px; + box-shadow: 0 0 0 6px var(--dai-ai-glow); +} + +/* Live indicator — subtle, not a large pill. */ +.dai-root .dai-live { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 550; + color: #047857; + background: rgba(16, 185, 129, 0.08); + white-space: nowrap; +} + +.dai-root .dai-live-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--dai-live); + animation: dai-pulse 2.4s ease-in-out infinite; +} + +@keyframes dai-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.55; + transform: scale(0.85); + } +} + +/* Page-context strip — "Orders · Today · All locations" */ +.dai-root .dai-context { + flex: 0 0 auto; + padding: 7px 14px; + font-size: 11.5px; + color: var(--dai-text-muted); + background: var(--dai-surface-alt); + border-bottom: 1px solid var(--dai-border); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* -------------------------------------------------------------------------- + Scroll region + -------------------------------------------------------------------------- */ +.dai-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--dai-border-strong) transparent; +} + +.dai-scroll::-webkit-scrollbar { + width: 8px; +} + +.dai-scroll::-webkit-scrollbar-thumb { + background: var(--dai-border-strong); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.dai-scroll::-webkit-scrollbar-track { + background: transparent; +} + +/* -------------------------------------------------------------------------- + Welcome state + -------------------------------------------------------------------------- */ +.dai-root .dai-welcome { + padding: 22px 14px 8px; +} + +.dai-root .dai-welcome-greeting { + font-size: 17px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--dai-text); +} + +.dai-root .dai-welcome-lead { + font-size: 13.5px; + line-height: 1.5; + color: var(--dai-text-secondary); +} + +.dai-root .dai-welcome-note { + font-size: 12px; + line-height: 1.5; + color: var(--dai-text-muted); +} + +.dai-root .dai-section-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--dai-text-muted); +} + +/* -------------------------------------------------------------------------- + Suggestion cards + -------------------------------------------------------------------------- */ +.dai-suggestion { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 11px; + text-align: left; + font: inherit; + color: var(--dai-text); + background: var(--dai-surface); + border: 1px solid var(--dai-border); + border-radius: 12px; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} + +.dai-suggestion:hover { + background: var(--dai-surface-alt); + border-color: var(--dai-border-strong); +} + +.dai-suggestion:active { + transform: scale(0.99); +} + +.dai-suggestion:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; +} + +.dai-root .dai-suggestion-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 26px; + height: 26px; + border-radius: 8px; + background: var(--dai-surface-hover); + color: var(--dai-text-secondary); +} + +.dai-root .dai-suggestion-text { + flex: 1 1 auto; + font-size: 13px; + line-height: 1.35; +} + +.dai-root .dai-suggestion-arrow { + flex: 0 0 auto; + color: var(--dai-text-muted); + opacity: 0; + transform: translateX(-3px); + transition: + opacity 140ms ease, + transform 140ms ease; +} + +.dai-suggestion:hover .dai-suggestion-arrow, +.dai-suggestion:focus-visible .dai-suggestion-arrow { + opacity: 1; + transform: translateX(0); +} + +/* Plain text link-button ("View more", "Sources") */ +.dai-link { + font: inherit; + font-size: 12px; + color: var(--dai-text-secondary); + background: none; + border: none; + padding: 2px 0; + cursor: pointer; + align-self: flex-start; +} + +.dai-link:hover { + color: var(--dai-text); + text-decoration: underline; +} + +.dai-link:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; + border-radius: 4px; +} + +/* -------------------------------------------------------------------------- + Conversation + -------------------------------------------------------------------------- */ +.dai-root .dai-thread { + padding: 16px 14px 8px; +} + +.dai-root .dai-msg { + animation: dai-enter 220ms var(--dai-ease) both; +} + +@keyframes dai-enter { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +/* User — compact, right aligned, brand surface. */ +.dai-root .dai-msg-user { + max-width: 82%; + margin-left: auto; + padding: 8px 12px; + border-radius: 14px; + border-bottom-right-radius: 6px; + font-size: 13.5px; + line-height: 1.45; + color: var(--dai-accent-contrast); + background: var(--dai-accent); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* Assistant — no bubble. Text sits on the panel surface. */ +.dai-root .dai-msg-ai { + font-size: 13.5px; + line-height: 1.5; + color: var(--dai-text); + overflow-wrap: anywhere; +} + +.dai-root .dai-msg-ai-detail { + font-size: 12.5px; + line-height: 1.55; + color: var(--dai-text-secondary); + white-space: pre-line; +} + +.dai-root .dai-msg-name { + font-size: 11.5px; + font-weight: 600; + color: var(--dai-text-secondary); +} + +.dai-root .dai-msg-footer { + font-size: 11px; + color: var(--dai-text-muted); +} + +/* Copy button — only revealed on hover of the message row. */ +.dai-root .dai-msg-actions { + opacity: 0; + transition: opacity 140ms ease; +} + +.dai-root .dai-msg-row:hover .dai-msg-actions, +.dai-root .dai-msg-row:focus-within .dai-msg-actions { + opacity: 1; +} + +/* -------------------------------------------------------------------------- + Structured metrics + -------------------------------------------------------------------------- */ +.dai-root .dai-stat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + width: 100%; +} + +.dai-root .dai-stat { + padding: 9px 10px; + border: 1px solid var(--dai-border); + border-radius: 10px; + background: var(--dai-surface-alt); +} + +.dai-root .dai-stat-value { + font-size: 19px; + font-weight: 650; + line-height: 1.1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.dai-root .dai-stat-label { + font-size: 11px; + color: var(--dai-text-muted); +} + +/* Headline metric — the "primary number is large" rule. */ +.dai-root .dai-metric-value { + font-size: 28px; + font-weight: 680; + line-height: 1.05; + letter-spacing: -0.025em; + color: var(--dai-text); + font-variant-numeric: tabular-nums; +} + +.dai-root .dai-metric-label { + font-size: 12px; + color: var(--dai-text-secondary); +} + +/* -------------------------------------------------------------------------- + Typing / loading + -------------------------------------------------------------------------- */ +.dai-root .dai-typing { + display: inline-flex; + align-items: center; + gap: 4px; + height: 18px; +} + +.dai-root .dai-typing span { + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--dai-text-muted); + animation: dai-bounce 1.3s ease-in-out infinite; +} + +.dai-root .dai-typing span:nth-child(2) { + animation-delay: 0.16s; +} + +.dai-root .dai-typing span:nth-child(3) { + animation-delay: 0.32s; +} + +@keyframes dai-bounce { + 0%, + 70%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + 35% { + opacity: 0.9; + transform: translateY(-3px); + } +} + +.dai-root .dai-shimmer { + height: 9px; + border-radius: 999px; + background: linear-gradient(90deg, var(--dai-surface-hover) 25%, #e9eef5 37%, var(--dai-surface-hover) 63%); + background-size: 400% 100%; + animation: dai-shimmer 1.5s ease infinite; +} + +@keyframes dai-shimmer { + from { + background-position: 100% 50%; + } + to { + background-position: 0 50%; + } +} + +/* -------------------------------------------------------------------------- + States (error / empty) + -------------------------------------------------------------------------- */ +.dai-root .dai-state { + padding: 12px; + border: 1px solid var(--dai-border); + border-radius: 12px; + background: var(--dai-surface-alt); +} + +.dai-root .dai-state-title { + font-size: 13px; + font-weight: 600; + color: var(--dai-text); +} + +.dai-root .dai-state-body { + font-size: 12.5px; + line-height: 1.5; + color: var(--dai-text-secondary); +} + +.dai-root .dai-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 8px; + flex: 0 0 auto; +} + +/* -------------------------------------------------------------------------- + Jump-to-latest + -------------------------------------------------------------------------- */ +.dai-jump { + position: absolute; + left: 50%; + bottom: 8px; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 11px; + font: inherit; + font-size: 12px; + color: var(--dai-text); + background: var(--dai-surface); + border: 1px solid var(--dai-border-strong); + border-radius: 999px; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1); + cursor: pointer; + z-index: 2; +} + +.dai-jump:hover { + background: var(--dai-surface-alt); +} + +.dai-jump:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; +} + +.dai-scroll-wrap { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* -------------------------------------------------------------------------- + Composer + -------------------------------------------------------------------------- */ +.dai-root .dai-composer-wrap { + flex: 0 0 auto; + padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--dai-border); + background: var(--dai-surface); +} + +.dai-root .dai-composer { + border: 1px solid var(--dai-border-strong); + border-radius: 16px; + background: var(--dai-surface); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + padding: 8px 8px 6px 12px; + transition: + border-color 140ms ease, + box-shadow 140ms ease; +} + +.dai-root .dai-composer[data-focused='true'] { + border-color: var(--dai-accent); + box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06); +} + +.dai-root .dai-composer textarea { + display: block; + width: 100%; + border: none; + outline: none; + resize: none; + padding: 0; + margin: 0; + font: inherit; + font-size: 13.5px; + line-height: 1.45; + color: var(--dai-text); + background: transparent; + max-height: 108px; /* ~5 lines */ + overflow-y: auto; +} + +.dai-root .dai-composer textarea::placeholder { + color: var(--dai-text-muted); +} + +.dai-send { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 30px; + height: 30px; + border: none; + border-radius: 999px; + color: var(--dai-accent-contrast); + background: var(--dai-accent); + cursor: pointer; + transition: + opacity 140ms ease, + transform 140ms ease; +} + +.dai-send:hover:not(:disabled) { + opacity: 0.88; +} + +.dai-send:active:not(:disabled) { + transform: scale(0.93); +} + +.dai-send:disabled { + opacity: 0.28; + cursor: default; +} + +.dai-send:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; +} + +.dai-root .dai-hint { + font-size: 11px; + color: var(--dai-text-muted); +} + +/* -------------------------------------------------------------------------- + Trigger (lives in the app TopNav) + -------------------------------------------------------------------------- */ +.dai-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border-radius: 9px; + border: 1px solid transparent; + background: transparent; + color: #4f46e5; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} + +.dai-trigger:hover { + background: rgba(99, 102, 241, 0.09); + border-color: rgba(99, 102, 241, 0.16); +} + +.dai-trigger:active { + transform: scale(0.94); +} + +.dai-trigger:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; +} + +.dai-trigger[data-active='true'] { + color: #ffffff; + background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to)); + border-color: transparent; +} + +/* -------------------------------------------------------------------------- + Responsive + -------------------------------------------------------------------------- */ +@media (max-width: 1279px) { + .dai-root { + --dai-panel-width: 396px; + } +} + +@media (max-width: 767px) { + .dai-root { + --dai-inset: 0px; + } + + .dai-panel { + width: 100vw; + max-width: 100vw; + border-radius: 0; + border: none; + } + + .dai-msg-user { + max-width: 88%; + } +} + +/* -------------------------------------------------------------------------- + Reduced motion — no slide, no pulse, no shimmer. Opacity only. + -------------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + .dai-panel, + .dai-scrim { + transition: opacity 1ms linear; + } + + .dai-panel { + transform: none; + } + + .dai-msg, + .dai-live-dot, + .dai-typing span, + .dai-shimmer, + .dai-suggestion, + .dai-send, + .dai-trigger { + animation: none !important; + transition: none !important; + } +} diff --git a/src/pages/nearle/assistant/DoormileAI/AIComposer.js b/src/pages/nearle/assistant/DoormileAI/AIComposer.js new file mode 100644 index 0000000..0d82528 --- /dev/null +++ b/src/pages/nearle/assistant/DoormileAI/AIComposer.js @@ -0,0 +1,90 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { LuArrowUp } from 'react-icons/lu'; + +import { HStack } from '@astryxdesign/core/HStack'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Text } from '@astryxdesign/core/Text'; +import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat'; + +// ==============================|| Doormile AI — composer ||============================== // +// +// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height), +// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's +// because the panel needs the compact anchored treatment and +// a circular send button, neither of which that component exposes — the +// dictation button it ships IS reused here rather than reinvented. + +const MAX_ROWS_PX = 108; + +const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => { + const textareaRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) }); + + // Grow with content, then let the stylesheet's max-height take over and + // scroll. Runs before paint so there's no visible jump on the first line + // break. + useLayoutEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`; + }, [value]); + + const canSend = value.trim().length > 0 && !isBusy; + + const submit = () => { + if (!canSend) return; + onSubmit(value); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + return ( + + +