feat: add floating AI chat widget to all pages

- Add AiChatWidget component with floating button in bottom-right
- Routes to /api/ai/chat/message for chat, ticket, form, cover letter intents
- Quick action buttons for common tasks
- Appears on all public pages via app.tsx root layout
- Appears on all dashboard pages via DashboardShell
This commit is contained in:
Tracewebstudio Dev 2026-04-15 20:03:47 +02:00
parent 6f550458e7
commit fe81ce54c7
3 changed files with 602 additions and 175 deletions

View file

@ -1,9 +1,10 @@
import { MetaProvider, Title } from '@solidjs/meta';
import { Router } from '@solidjs/router';
import { FileRoutes } from '@solidjs/start/router';
import { ErrorBoundary, Suspense } from 'solid-js';
import { AuthProvider } from '~/lib/auth';
import './app.css';
import { MetaProvider, Title } from "@solidjs/meta";
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { ErrorBoundary, Suspense } from "solid-js";
import { AuthProvider } from "~/lib/auth";
import { AiChatWidget } from "~/components/AiChatWidget";
import "./app.css";
export default function App() {
return (
@ -14,16 +15,34 @@ export default function App() {
<AuthProvider>
<ErrorBoundary
fallback={(err) => (
<main style={{ padding: '24px', 'font-family': 'Inter, system-ui, sans-serif', color: '#111827', background: '#fff' }}>
<h1 style={{ margin: 0, 'font-size': '20px' }}>Frontend Error</h1>
<p style={{ 'margin-top': '8px' }}>A runtime error occurred while rendering this page.</p>
<pre style={{ 'margin-top': '12px', padding: '12px', background: '#f3f4f6', 'border-radius': '8px', 'white-space': 'pre-wrap' }}>
<main
style={{
padding: "24px",
"font-family": "Inter, system-ui, sans-serif",
color: "#111827",
background: "#fff",
}}
>
<h1 style={{ margin: 0, "font-size": "20px" }}>Frontend Error</h1>
<p style={{ "margin-top": "8px" }}>
A runtime error occurred while rendering this page.
</p>
<pre
style={{
"margin-top": "12px",
padding: "12px",
background: "#f3f4f6",
"border-radius": "8px",
"white-space": "pre-wrap",
}}
>
{String((err as any)?.message || err)}
</pre>
</main>
)}
>
<Suspense>{props.children}</Suspense>
<AiChatWidget />
</ErrorBoundary>
</AuthProvider>
</MetaProvider>

View file

@ -0,0 +1,333 @@
import { createSignal, Show, For, onMount } from "solid-js";
import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid";
const API = "/api/gateway";
interface ChatMessage {
role: "user" | "assistant";
content: string;
intent?: string;
}
interface ChatResponse {
message: string;
conversation_id: string;
intent: string;
confidence: number;
}
export function AiChatWidget() {
const [isOpen, setIsOpen] = createSignal(false);
const [messages, setMessages] = createSignal<ChatMessage[]>([
{
role: "assistant",
content:
"Hi! I'm your AI assistant. I can help you create support tickets, fill out forms, generate job descriptions, or write cover letters. What can I help you with?",
},
]);
const [input, setInput] = createSignal("");
const [isLoading, setIsLoading] = createSignal(false);
const [conversationId, setConversationId] = createSignal("");
const toggleChat = () => setIsOpen((v) => !v);
const sendMessage = async () => {
const text = input().trim();
if (!text || isLoading()) return;
setIsLoading(true);
const userMessage: ChatMessage = { role: "user", content: text };
setMessages((prev) => [...prev, userMessage]);
setInput("");
try {
const res = await fetch(`${API}/api/ai/chat/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
conversation_id: conversationId() || undefined,
}),
});
if (!res.ok) throw new Error("AI request failed");
const data: ChatResponse = await res.json();
if (data.conversation_id && !conversationId()) {
setConversationId(data.conversation_id);
}
const assistantMessage: ChatMessage = {
role: "assistant",
content: data.message,
intent: data.intent,
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (err) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content:
"I'm having trouble connecting right now. Please try again or contact support@nxtgauge.com.",
},
]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<>
{/* Floating button */}
<button
onClick={toggleChat}
style={{
position: "fixed",
bottom: "24px",
right: "24px",
width: "56px",
height: "56px",
"border-radius": "50%",
background: "#FF5E13",
border: "none",
cursor: "pointer",
display: "flex",
"align-items": "center",
"justify-content": "center",
"box-shadow": "0 4px 16px rgba(255, 90, 19, 0.35)",
"z-index": "9999",
transition: "transform 0.2s",
}}
title="AI Assistant"
>
<Show when={isOpen()} fallback={<MessageCircle size={24} color="#fff" />}>
<X size={24} color="#fff" />
</Show>
</button>
{/* Chat window */}
<Show when={isOpen()}>
<div
style={{
position: "fixed",
bottom: "96px",
right: "24px",
width: "380px",
height: "520px",
background: "#fff",
"border-radius": "16px",
"box-shadow": "0 8px 40px rgba(0,0,0,0.15)",
display: "flex",
"flex-direction": "column",
"z-index": "9998",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
background: "linear-gradient(135deg, #FF5E13 0%, #E5470F 100%)",
padding: "16px 20px",
display: "flex",
"align-items": "center",
"justify-content": "space-between",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<Bot size={22} color="#fff" />
<div>
<p style={{ margin: 0, color: "#fff", "font-weight": "700", "font-size": "15px" }}>
AI Assistant
</p>
<p style={{ margin: 0, color: "rgba(255,255,255,0.8)", "font-size": "11px" }}>
Powered by gemma3
</p>
</div>
</div>
<button
onClick={toggleChat}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px",
}}
>
<X size={20} color="#fff" />
</button>
</div>
{/* Quick actions */}
<div
style={{
padding: "10px 16px",
"border-bottom": "1px solid #E5E7EB",
display: "flex",
gap: "8px",
"flex-wrap": "wrap",
}}
>
{["Create Ticket", "Job Description", "Cover Letter", "Fill Form"].map((label) => (
<button
onClick={() => {
setInput(`${label.toLowerCase()}: `);
}}
style={{
padding: "4px 10px",
"border-radius": "20px",
border: "1px solid #E5E7EB",
background: "#F9FAFB",
"font-size": "11px",
cursor: "pointer",
color: "#374151",
}}
>
{label}
</button>
))}
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflow: "auto",
padding: "16px",
display: "flex",
"flex-direction": "column",
gap: "12px",
}}
>
<For each={messages()}>
{(msg) => (
<div
style={{
display: "flex",
"align-items": "flex-start",
gap: "8px",
"flex-direction": msg.role === "user" ? "row-reverse" : "row",
}}
>
<div
style={{
width: "28px",
height: "28px",
"border-radius": "50%",
background: msg.role === "user" ? "#FF5E13" : "#E5E7EB",
display: "flex",
"align-items": "center",
"justify-content": "center",
"flex-shrink": 0,
}}
>
<Show when={msg.role === "user"} fallback={<Bot size={14} color="#6B7280" />}>
<User size={14} color="#fff" />
</Show>
</div>
<div
style={{
"max-width": "75%",
padding: "10px 14px",
"border-radius": "14px",
background: msg.role === "user" ? "#FF5E13" : "#F3F4F6",
color: msg.role === "user" ? "#fff" : "#111827",
"font-size": "13px",
"line-height": "1.5",
}}
>
<p style={{ margin: 0, "white-space": "pre-wrap" }}>{msg.content}</p>
<Show when={msg.intent && msg.role === "assistant"}>
<p
style={{
margin: "4px 0 0",
"font-size": "10px",
color: "#9CA3AF",
"font-style": "italic",
}}
>
Intent: {msg.intent}
</p>
</Show>
</div>
</div>
)}
</For>
<Show when={isLoading()}>
<div
style={{
display: "flex",
"align-items": "center",
gap: "8px",
color: "#9CA3AF",
"font-size": "13px",
}}
>
<Loader size={14} style={{ animation: "spin 1s linear infinite" }} />
Thinking...
</div>
</Show>
</div>
{/* Input */}
<div
style={{
padding: "12px 16px",
"border-top": "1px solid #E5E7EB",
display: "flex",
gap: "8px",
}}
>
<input
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything..."
style={{
flex: 1,
height: "40px",
"border-radius": "20px",
border: "1px solid #E5E7EB",
padding: "0 16px",
"font-size": "13px",
outline: "none",
}}
/>
<button
onClick={sendMessage}
disabled={isLoading() || !input().trim()}
style={{
width: "40px",
height: "40px",
"border-radius": "50%",
background: isLoading() ? "#E5E7EB" : "#FF5E13",
border: "none",
cursor: isLoading() ? "default" : "pointer",
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Send size={16} color="#fff" />
</button>
</div>
</div>
</Show>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</>
);
}

View file

@ -3,36 +3,49 @@
* Used for pages that need actual backend connectivity
* (My Profile, My Portfolio, Verification) instead of the preview mock.
*/
import { For, JSX, Show, createMemo, createSignal, onMount } from 'solid-js';
import { For, JSX, Show, createMemo, createSignal, onMount } from "solid-js";
import { AiChatWidget } from "./AiChatWidget";
import {
User, Briefcase, LayoutDashboard, FolderOpen, MapPin, Star,
CreditCard, Globe, ShieldCheck, HelpCircle, Settings,
RefreshCw, LogOut, Bell, ChevronRight,
} from 'lucide-solid';
User,
Briefcase,
LayoutDashboard,
FolderOpen,
MapPin,
Star,
CreditCard,
Globe,
ShieldCheck,
HelpCircle,
Settings,
RefreshCw,
LogOut,
Bell,
ChevronRight,
} from "lucide-solid";
// ── Icon map (matches DashboardDesignPreview sidebar keys) ────────────────────
const ICON_MAP: Record<string, any> = {
'my dashboard': LayoutDashboard,
'my profile': User,
'my portfolio': FolderOpen,
'leads': MapPin,
'my responses': Star,
'credits': CreditCard,
'explore nxtgauge': Globe,
'verification': ShieldCheck,
'help center': HelpCircle,
'settings': Settings,
'switch services': RefreshCw,
'jobs': Briefcase,
'applications': Briefcase,
'shortlisted candidates': User,
'my applications': FolderOpen,
'saved jobs': Star,
'my requirements': FolderOpen,
'received responses': Bell,
'shortlisted responses': Star,
'logout': LogOut,
"my dashboard": LayoutDashboard,
"my profile": User,
"my portfolio": FolderOpen,
leads: MapPin,
"my responses": Star,
credits: CreditCard,
"explore nxtgauge": Globe,
verification: ShieldCheck,
"help center": HelpCircle,
settings: Settings,
"switch services": RefreshCw,
jobs: Briefcase,
applications: Briefcase,
"shortlisted candidates": User,
"my applications": FolderOpen,
"saved jobs": Star,
"my requirements": FolderOpen,
"received responses": Bell,
"shortlisted responses": Star,
logout: LogOut,
};
function SidebarIcon(props: { label: string }) {
@ -42,9 +55,9 @@ function SidebarIcon(props: { label: string }) {
}
function titleCase(value: string) {
return String(value || '')
return String(value || "")
.toLowerCase()
.replace(/_/g, ' ')
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
@ -61,14 +74,14 @@ interface Props {
// ── Brand colours ─────────────────────────────────────────────────────────────
const ORANGE = '#FF5E13';
const NAVY = '#0D0D2A';
const ORANGE = "#FF5E13";
const NAVY = "#0D0D2A";
// ── Component ─────────────────────────────────────────────────────────────────
export default function DashboardShell(props: Props) {
const roleLabel = createMemo(() => {
const k = String(props.roleKey || '').replace(/_/g, ' ');
const k = String(props.roleKey || "").replace(/_/g, " ");
return k.charAt(0).toUpperCase() + k.slice(1).toLowerCase();
});
@ -77,18 +90,21 @@ export default function DashboardShell(props: Props) {
// Fetch unread notification count
const fetchUnreadCount = async () => {
try {
const token = typeof window !== 'undefined' ? window.sessionStorage.getItem('nxtgauge_access_token') || '' : '';
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
if (!token) return;
const res = await fetch('/api/me/notifications/unread-count', {
const res = await fetch("/api/me/notifications/unread-count", {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
credentials: "include",
});
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unread_count || 0);
}
} catch (e) {
console.error('Failed to fetch unread count:', e);
console.error("Failed to fetch unread count:", e);
}
};
@ -100,68 +116,94 @@ export default function DashboardShell(props: Props) {
});
return (
<div style={{
display: 'flex',
'min-height': '100vh',
background: '#F8FAFC',
'font-family': "'Exo 2', sans-serif",
}}>
<div
style={{
display: "flex",
"min-height": "100vh",
background: "#F8FAFC",
"font-family": "'Exo 2', sans-serif",
}}
>
{/* ── Sidebar ──────────────────────────────────────────────────────── */}
<aside style={{
width: '220px',
'flex-shrink': '0',
background: '#FFFFFF',
display: 'flex',
'flex-direction': 'column',
'padding': '0',
'min-height': '100vh',
position: 'sticky',
top: '0',
height: '100vh',
'overflow-y': 'auto',
}}>
<aside
style={{
width: "220px",
"flex-shrink": "0",
background: "#FFFFFF",
display: "flex",
"flex-direction": "column",
padding: "0",
"min-height": "100vh",
position: "sticky",
top: "0",
height: "100vh",
"overflow-y": "auto",
}}
>
{/* Logo */}
<div style={{ padding: '20px 16px 12px', 'border-bottom': '1px solid #E5E7EB' }}>
<img src="/nxtgauge-logo.png" alt="Nxtgauge" style={{ height: '40px', 'object-fit': 'contain', 'max-width': '170px' }} />
<div style={{ padding: "20px 16px 12px", "border-bottom": "1px solid #E5E7EB" }}>
<img
src="/nxtgauge-logo.png"
alt="Nxtgauge"
style={{ height: "40px", "object-fit": "contain", "max-width": "170px" }}
/>
</div>
{/* Role badge */}
<div style={{ padding: '10px 16px', 'border-bottom': '1px solid #E5E7EB' }}>
<p style={{ margin: '0', 'font-size': '10px', 'letter-spacing': '0.08em', 'text-transform': 'uppercase', color: '#6B7280' }}>Active Role</p>
<p style={{ margin: '2px 0 0', 'font-size': '12px', 'font-weight': '700', color: '#111827' }}>{roleLabel()}</p>
<div style={{ padding: "10px 16px", "border-bottom": "1px solid #E5E7EB" }}>
<p
style={{
margin: "0",
"font-size": "10px",
"letter-spacing": "0.08em",
"text-transform": "uppercase",
color: "#6B7280",
}}
>
Active Role
</p>
<p
style={{
margin: "2px 0 0",
"font-size": "12px",
"font-weight": "700",
color: "#111827",
}}
>
{roleLabel()}
</p>
</div>
{/* Nav items */}
<nav style={{ flex: '1', padding: '8px 8px' }}>
<nav style={{ flex: "1", padding: "8px 8px" }}>
<For each={props.sidebarItems}>
{(item) => {
const isActive = () => item.toLowerCase() === props.activeSidebar.toLowerCase();
const isLogout = item.toLowerCase() === 'logout';
const isLogout = item.toLowerCase() === "logout";
return (
<button
type="button"
onClick={() => props.onSidebarSelect(item)}
style={{
display: 'flex',
'align-items': 'center',
gap: '9px',
width: '100%',
'text-align': 'left',
height: '34px',
padding: '0 10px',
'border-radius': '8px',
border: 'none',
cursor: 'pointer',
'font-size': '12px',
'font-weight': '600',
'margin-bottom': '4px',
background: isActive() ? '#FFF3EE' : 'transparent',
color: isActive() ? ORANGE : isLogout ? '#DC2626' : '#6B7280',
transition: 'background 0.15s, color 0.15s',
display: "flex",
"align-items": "center",
gap: "9px",
width: "100%",
"text-align": "left",
height: "34px",
padding: "0 10px",
"border-radius": "8px",
border: "none",
cursor: "pointer",
"font-size": "12px",
"font-weight": "600",
"margin-bottom": "4px",
background: isActive() ? "#FFF3EE" : "transparent",
color: isActive() ? ORANGE : isLogout ? "#DC2626" : "#6B7280",
transition: "background 0.15s, color 0.15s",
}}
>
<span style={{ 'flex-shrink': '0', color: isActive() ? ORANGE : '#9CA3AF' }}>
<span style={{ "flex-shrink": "0", color: isActive() ? ORANGE : "#9CA3AF" }}>
<SidebarIcon label={item} />
</span>
{titleCase(item)}
@ -172,61 +214,94 @@ export default function DashboardShell(props: Props) {
</nav>
{/* User footer */}
<div style={{ padding: '12px 16px', 'border-top': '1px solid #E5E7EB' }}>
<p style={{ margin: '0', 'font-size': '12px', 'font-weight': '600', color: '#374151', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' }}>
{props.userName || 'User'}
<div style={{ padding: "12px 16px", "border-top": "1px solid #E5E7EB" }}>
<p
style={{
margin: "0",
"font-size": "12px",
"font-weight": "600",
color: "#374151",
overflow: "hidden",
"text-overflow": "ellipsis",
"white-space": "nowrap",
}}
>
{props.userName || "User"}
</p>
</div>
</aside>
{/* ── Main content ─────────────────────────────────────────────────── */}
<div style={{ flex: '1', display: 'flex', 'flex-direction': 'column', 'min-width': '0' }}>
<div style={{ flex: "1", display: "flex", "flex-direction": "column", "min-width": "0" }}>
{/* Top bar */}
<header style={{
height: '56px',
background: '#fff',
'border-bottom': '1px solid #E5E7EB',
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
padding: '0 24px',
'flex-shrink': '0',
}}>
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '700', color: NAVY }}>
<header
style={{
height: "56px",
background: "#fff",
"border-bottom": "1px solid #E5E7EB",
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "0 24px",
"flex-shrink": "0",
}}
>
<p style={{ margin: "0", "font-size": "15px", "font-weight": "700", color: NAVY }}>
{titleCase(props.activeSidebar)}
</p>
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
<button type="button" style={{ position: 'relative', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', 'align-items': 'center', 'justify-content': 'center', padding: 0 }}>
<Bell size={18} style={{ color: '#9CA3AF' }} />
<Show when={unreadCount() > 0}>
<span style={{
position: 'absolute',
top: '-2px',
right: '-2px',
width: '8px',
height: '8px',
background: '#FF5E13',
'border-radius': '50%',
border: '1px solid white'
}}></span>
</Show>
</button>
<div style={{
width: '32px', height: '32px', 'border-radius': '999px',
background: ORANGE, color: '#fff', display: 'flex',
'align-items': 'center', 'justify-content': 'center',
'font-size': '13px', 'font-weight': '700',
}}>
{(props.userName || 'U').charAt(0).toUpperCase()}
</div>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<button
type="button"
style={{
position: "relative",
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
"align-items": "center",
"justify-content": "center",
padding: 0,
}}
>
<Bell size={18} style={{ color: "#9CA3AF" }} />
<Show when={unreadCount() > 0}>
<span
style={{
position: "absolute",
top: "-2px",
right: "-2px",
width: "8px",
height: "8px",
background: "#FF5E13",
"border-radius": "50%",
border: "1px solid white",
}}
></span>
</Show>
</button>
<div
style={{
width: "32px",
height: "32px",
"border-radius": "999px",
background: ORANGE,
color: "#fff",
display: "flex",
"align-items": "center",
"justify-content": "center",
"font-size": "13px",
"font-weight": "700",
}}
>
{(props.userName || "U").charAt(0).toUpperCase()}
</div>
</div>
</header>
{/* Page content */}
<main style={{ flex: '1', padding: '24px', 'overflow-y': 'auto' }}>
{props.children}
</main>
<main style={{ flex: "1", padding: "24px", "overflow-y": "auto" }}>{props.children}</main>
</div>
<AiChatWidget />
</div>
);
}
@ -234,65 +309,65 @@ export default function DashboardShell(props: Props) {
// ── Shared UI primitives ──────────────────────────────────────────────────────
export const CARD = {
background: '#fff',
border: '1px solid #E5E7EB',
'border-radius': '14px',
padding: '20px',
'box-shadow': '0 1px 4px rgba(0,0,0,0.06)',
background: "#fff",
border: "1px solid #E5E7EB",
"border-radius": "14px",
padding: "20px",
"box-shadow": "0 1px 4px rgba(0,0,0,0.06)",
} as const;
export const BTN_PRIMARY = {
height: '38px',
'border-radius': '10px',
border: 'none',
height: "38px",
"border-radius": "10px",
border: "none",
background: NAVY,
color: '#fff',
padding: '0 18px',
'font-size': '13px',
'font-weight': '700',
cursor: 'pointer',
color: "#fff",
padding: "0 18px",
"font-size": "13px",
"font-weight": "700",
cursor: "pointer",
} as const;
export const BTN_ORANGE = {
height: '38px',
'border-radius': '10px',
border: 'none',
height: "38px",
"border-radius": "10px",
border: "none",
background: ORANGE,
color: '#fff',
padding: '0 18px',
'font-size': '13px',
'font-weight': '700',
cursor: 'pointer',
color: "#fff",
padding: "0 18px",
"font-size": "13px",
"font-weight": "700",
cursor: "pointer",
} as const;
export const BTN_GHOST = {
height: '38px',
'border-radius': '10px',
border: '1px solid #E5E7EB',
background: '#fff',
color: '#374151',
padding: '0 18px',
'font-size': '13px',
'font-weight': '600',
cursor: 'pointer',
height: "38px",
"border-radius": "10px",
border: "1px solid #E5E7EB",
background: "#fff",
color: "#374151",
padding: "0 18px",
"font-size": "13px",
"font-weight": "600",
cursor: "pointer",
} as const;
export const INPUT = {
height: '40px',
width: '100%',
'border-radius': '8px',
border: '1px solid #E5E7EB',
padding: '0 12px',
'font-size': '14px',
color: '#111827',
background: '#fff',
'box-sizing': 'border-box',
height: "40px",
width: "100%",
"border-radius": "8px",
border: "1px solid #E5E7EB",
padding: "0 12px",
"font-size": "14px",
color: "#111827",
background: "#fff",
"box-sizing": "border-box",
} as const;
export const LABEL = {
display: 'block',
'font-size': '12px',
'font-weight': '600',
color: '#374151',
'margin-bottom': '6px',
display: "block",
"font-size": "12px",
"font-weight": "600",
color: "#374151",
"margin-bottom": "6px",
} as const;