update ui update and fix layout issue

This commit is contained in:
2026-08-06 13:21:10 +05:30
parent 92322bff16
commit e5f8144fb3
251 changed files with 9041 additions and 2119 deletions

View File

@@ -0,0 +1,100 @@
'use client';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {ICONS} from '@/shared/utils/icons';
/**
* Minimal by design: a wordmark and three controls.
*
* What used to be here — a three-tab strip (AI / Analytics / Chat) — made the
* assistant read as a dashboard widget with a chat feature. A conversational
* product does not ask you to choose a mode before it will talk to you, so the
* tabs are gone and the conversation is the surface.
*
* `onClose` is optional because the two hosts differ: the slide-over closes
* itself, the inline panel is collapsed by the top bar's toggle, and rendering
* a close button with nothing to close would be a lie.
*/
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
export function ChatHeader({onClose}: {onClose?: () => void}) {
const {
newChat,
isHistoryOpen,
setHistoryOpen,
panelMode,
toggleExpand,
toggleFullscreen,
} = useLoyalyAi();
const isExpanded = panelMode === 'expanded';
const isFullscreen = panelMode === 'fullscreen';
return (
<HStack
vAlign="center"
hAlign="between"
gap={2}
paddingInline={4}
paddingBlock={3}
width="100%"
// The header is the only thing pinned above the scroll area, so it owns
// the hairline that separates the conversation from the chrome.
className="border-b border-border shrink-0"
>
<HStack gap={2} vAlign="center">
<BrandLogo height={20} />
</HStack>
<HStack gap={0.5} vAlign="center">
<IconButton
variant="ghost"
size="sm"
label={isExpanded ? 'Restore width' : 'Expand panel'}
tooltip={isExpanded ? 'Restore' : 'Expand'}
icon={<Icon icon={isExpanded ? ICONS.restore : ICONS.expand} size="sm" />}
onClick={toggleExpand}
/>
<IconButton
variant="ghost"
size="sm"
label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
tooltip={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
icon={<Icon icon={isFullscreen ? ICONS.minimize : ICONS.fullscreen} size="sm" />}
onClick={toggleFullscreen}
/>
<IconButton
variant="ghost"
size="sm"
label="New chat"
tooltip="New chat"
icon={<Icon icon={ICONS.newChat} size="sm" />}
onClick={newChat}
/>
<IconButton
variant="ghost"
size="sm"
label="Chat history"
tooltip="History"
icon={<Icon icon={ICONS.history} size="sm" />}
onClick={() => setHistoryOpen(!isHistoryOpen)}
aria-expanded={isHistoryOpen}
/>
{onClose ? (
<IconButton
variant="ghost"
size="sm"
label="Close Loyaly AI"
tooltip="Close"
icon={<Icon icon="close" size="sm" />}
onClick={onClose}
/>
) : null}
</HStack>
</HStack>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import {useEffect, useRef} from 'react';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {HStack} from '@astryxdesign/core/Layout';
import {ICONS} from '@/shared/utils/icons';
/**
* The composer: one pill, three zones.
*
* [ + ] Ask Loyaly AI about your business... [ mic ] [ ↑ ]
*
* ── Why this is hand-built and not Astryx's ChatComposer ─────────────────
* ChatComposer is a multi-row surface: `headerActions` render in a row ABOVE
* the input, `footerActions` in a row below. That is the right shape for a
* desktop IDE assistant with model pickers and context chips, and the wrong
* shape for the single 5664px pill the brief specifies, where the attachment
* button sits inline to the LEFT of the text. Bending it into one row would
* have meant fighting its layout with utilities on every slot.
*
* What is NOT hand-built is anything that carries behaviour: the docking and
* auto-scroll come from ChatLayout, which this is passed to as `composer`.
*
* ── Auto-grow ─────────────────────────────────────────────────────────────
* The textarea grows with its content up to MAX_ROWS then scrolls, which is
* why the height is a range rather than a number. Measured by resetting height
* to `auto` before reading scrollHeight — without the reset, scrollHeight can
* only ever grow, and the box never shrinks back after a deletion.
*/
/** ~5 lines before the field starts scrolling instead of growing. */
const MAX_HEIGHT_PX = 132;
const PILL = [
'rounded-[28px] bg-card border border-border shadow-md',
'transition-colors duration-150',
'focus-within:border-border-strong',
'pl-4 pr-2 py-1.5',
].join(' ');
const FIELD = [
'flex-1 min-w-0 resize-none bg-transparent border-0 outline-none',
'text-sm text-primary placeholder:text-secondary',
'py-2 leading-6 overflow-y-auto',
].join(' ');
export function Composer({
value,
onChange,
onSubmit,
onStop,
isStreaming,
onFocus,
onBlur,
placeholder = 'Ask Loyaly AI about your business...',
}: {
value: string;
onChange: (v: string) => void;
onSubmit: (v: string) => void;
onStop: () => void;
isStreaming: boolean;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
}) {
const fieldRef = useRef<HTMLTextAreaElement>(null);
const canSend = value.trim().length > 0 && !isStreaming;
// Auto-grow. Runs on every value change, including the reset to '' after a
// send — which is what shrinks the pill back to one line.
useEffect(() => {
const field = fieldRef.current;
if (!field) return;
field.style.height = 'auto';
field.style.height = `${Math.min(field.scrollHeight, MAX_HEIGHT_PX)}px`;
}, [value]);
const submit = () => {
if (!canSend) return;
onSubmit(value);
};
return (
<HStack gap={2} vAlign="center" width="100%" className={PILL}>
<textarea
ref={fieldRef}
className={FIELD}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
rows={1}
aria-label="Message Loyaly AI"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
}}
/>
<HStack gap={1} vAlign="center" className="shrink-0 pb-0.5">
<IconButton
variant="ghost"
size="sm"
label="Dictate a message"
tooltip="Dictate"
icon={<Icon icon={ICONS.mic} size="sm" />}
isDisabled
className="size-9 rounded-full flex items-center justify-center"
/>
{isStreaming ? (
<IconButton
variant="secondary"
size="sm"
label="Stop generating"
tooltip="Stop"
icon={<Icon icon={ICONS.stop} size="sm" />}
onClick={onStop}
className="size-9 rounded-full flex items-center justify-center"
/>
) : (
<IconButton
variant="primary"
size="sm"
label="Send message"
icon={<Icon icon={ICONS.send} size="sm" />}
isDisabled={!canSend}
onClick={submit}
className="size-9 rounded-full flex items-center justify-center"
/>
)}
</HStack>
</HStack>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import {useState} from 'react';
import {motion, AnimatePresence} from 'framer-motion';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {Composer} from './Composer';
import {EmptyState} from './EmptyState';
import {SuggestionChips} from './SuggestionChips';
import {MessageList} from './MessageList';
/**
* ChatGPT & Gemini style two-stage chat experience:
*
* 1. STAGE 1 — Initial Minimal Landing:
* - Vertically centered Loyaly logo mark, heading, subtitle, and composer.
* - No suggestion chips shown initially to keep interface calm and minimal.
*
* 2. STAGE 2 — Input Focused / Typing:
* - Focusing or typing in composer smoothly animates suggestion chips into view directly BELOW the composer.
*
* 3. Active Conversation Mode:
* - First message sent removes landing elements and morphs composer to sticky bottom.
*
* 4. New Chat:
* - Returns to STAGE 1 (minimal landing, chips hidden until focused again).
*/
export function Conversation() {
const {conversation, draft, setDraft, send, stop, isStreaming} =
useLoyalyAi();
const [isFocused, setIsFocused] = useState(false);
const hasMessages = conversation.messages.length > 0;
const showChips = isFocused || draft.trim().length > 0;
return (
<div className="flex-1 flex flex-col h-full w-full relative overflow-hidden bg-background">
<AnimatePresence>
{!hasMessages ? (
<motion.div
key="empty-landing-view"
initial={{opacity: 0, scale: 0.98}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0, scale: 0.96, transition: {duration: 0.2}}}
transition={{duration: 0.3, ease: 'easeOut'}}
className="flex-1 flex flex-col justify-center items-center px-4 py-8 w-full max-w-2xl mx-auto overflow-y-auto"
>
<div className="w-full flex-1 flex flex-col items-center justify-center my-auto -mt-20 sm:-mt-28 py-4">
<EmptyState />
{/* Chat Composer (36px gap from subtitle) */}
<motion.div
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
className="w-full max-w-xl mt-9"
>
<Composer
value={draft}
onChange={setDraft}
onSubmit={send}
onStop={stop}
isStreaming={isStreaming}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
</motion.div>
{/* STAGE 2 — Suggestion Chips animate in BELOW the composer when focused or typing */}
<AnimatePresence>
{showChips && (
<motion.div
key="stage-2-chips"
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -8}}
transition={{duration: 0.22, ease: 'easeOut'}}
className="w-full max-w-xl mt-[28px]"
>
<SuggestionChips onPick={send} />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
) : (
<motion.div
key="active-chat-view"
/*
* No initial/animate opacity here.
*
* The composer below shares a `layoutId` with the one in the empty
* view, and framer defers an entering element's `animate` while it
* projects a shared layout across the swap — which left this view
* mounted at `opacity: 0` with the conversation invisible behind
* it (measured: two messages in the DOM, blank panel). The morph
* itself already carries the continuity this fade was for.
*/
className="flex-1 flex flex-col h-full w-full relative overflow-hidden"
>
<div className="flex-1 overflow-y-auto px-4 py-6 w-full">
<div className="max-w-4xl mx-auto w-full space-y-4">
<MessageList
messages={conversation.messages}
isStreaming={isStreaming}
/>
</div>
</div>
<motion.div
transition={{duration: 0.3, ease: [0.16, 1, 0.3, 1]}}
className="p-3 border-t border-border bg-popover/90 backdrop-blur-md sticky bottom-0 z-10 w-full shrink-0"
>
<div className="max-w-3xl mx-auto w-full">
<Composer
value={draft}
onChange={setDraft}
onSubmit={send}
onStop={stop}
isStreaming={isStreaming}
/>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import {useState, useEffect} from 'react';
import {motion} from 'framer-motion';
import {Heading, Text} from '@astryxdesign/core/Text';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
const GREETINGS = [
'How can Loyaly AI help today?',
'What would you like to analyze today?',
'Need insights from your business?',
'What would you like to know?',
'Ready to optimize your stores?',
"Let's improve today's performance.",
'Ask anything about your business.',
'How can I help your team today?',
"Need help understanding today's numbers?",
'What should we explore today?',
];
const SUBTITLES = [
'Ask anything about sales, stores, staff or rewards.',
'Analyze your business with AI.',
'Get instant answers from your retail data.',
'Discover trends across all your stores.',
'Find opportunities to improve performance.',
'Your AI assistant for smarter retail decisions.',
];
export function EmptyState() {
const [greeting, setGreeting] = useState(GREETINGS[0]);
const [subtitle, setSubtitle] = useState(SUBTITLES[0]);
useEffect(() => {
const randomGreeting = GREETINGS[Math.floor(Math.random() * GREETINGS.length)];
const randomSubtitle = SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)];
setGreeting(randomGreeting);
setSubtitle(randomSubtitle);
}, []);
return (
<div className="flex flex-col items-center text-center w-full max-w-xl mx-auto">
{/* 1. Official Loyaly Heart Logo (56px desktop, 48px mobile) */}
<div className="mb-6 flex justify-center">
<BrandMark size={56} priority />
</div>
{/* 2. Dynamic Heading & Subtitle */}
<motion.div
key={greeting}
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.25, ease: 'easeOut'}}
className="flex flex-col items-center text-center"
>
<div className="mb-4">
<Heading level={2} justify="center" className="text-xl sm:text-2xl font-semibold">
{greeting}
</Heading>
</div>
<div className="max-w-md mx-auto">
<Text type="supporting" justify="center" className="text-sm">
{subtitle}
</Text>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {Item} from '@astryxdesign/core/Item';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
/**
* Past conversations, as an overlay INSIDE the panel.
*
* Not a second drawer off the side of the screen: the assistant already is a
* drawer at most widths, and a drawer opening out of a drawer is how you end
* up with two dismiss layers and a stack of Escape handlers that disagree.
* Covering the conversation area instead keeps the header — with the same
* History button, now toggled on — in place as the way back out.
*
* Absolutely positioned over the conversation rather than replacing it, so
* the transcript is not unmounted: opening history and closing it again
* returns to the same scroll position mid-conversation.
*/
export function HistoryDrawer() {
const {history, openConversation, isHistoryOpen, setHistoryOpen} =
useLoyalyAi();
if (!isHistoryOpen) return null;
return (
<VStack
gap={2}
padding={3}
width="100%"
className="absolute inset-0 z-10 overflow-y-auto bg-surface"
role="region"
aria-label="Chat history"
>
<HStack hAlign="between" vAlign="center" paddingInline={1}>
<Text size="sm" type="supporting">
Recent chats
</Text>
{/* A text button rather than an IconButton: this closes a region, not
the assistant, and an X here would be mistaken for the panel's own
close in the header directly above it. */}
<Button
variant="ghost"
size="sm"
label="Done"
onClick={() => setHistoryOpen(false)}
/>
</HStack>
{history.length === 0 ? (
<EmptyPanel
icon="chat"
title="No conversations yet"
description="Chats you start appear here for the rest of the session."
/>
) : (
<VStack gap={0.5}>
{history.map((c) => (
<Item
key={c.id}
label={c.title}
labelLines={1}
description={<Timestamp value={c.updatedAt} />}
density="balanced"
className="rounded-lg cursor-pointer"
startContent={<Icon icon={ICONS.chat} size="sm" />}
onClick={() => openConversation(c.id)}
/>
))}
</VStack>
)}
</VStack>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {ChatHeader} from './ChatHeader';
import {Conversation} from './Conversation';
import {HistoryDrawer} from './HistoryDrawer';
import {ResizeHandle} from './ResizeHandle';
/**
* The assistant surface, identical inline and in the slide-over.
*
* Three children and no branching: a header, the conversation, and the history
* overlay that covers the conversation when it is open. Everything it renders
* reads from LoyalyAiProvider, so it is safe to unmount and remount when the
* breakpoint changes presentation.
*
* `relative` on the frame is load-bearing — it is the positioning context
* HistoryDrawer's `absolute inset-0` resolves against, which is what keeps the
* overlay inside the panel instead of over the whole workspace.
*/
export function LoyalyAiPanel({onClose}: {onClose?: () => void}) {
return (
<VStack gap={0} height="100%" width="100%" className="relative">
<ResizeHandle />
<ChatHeader onClose={onClose} />
<Conversation />
<HistoryDrawer />
</VStack>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {LoyalyAiPanel} from './LoyalyAiPanel';
/**
* Tablet and mobile presentation.
*
* MobileNav rather than Dialog: a Dialog is content-sized (height:
* fit-content, maxHeight 480px) and ignores top/bottom insets, so it renders
* as a floating card rather than a full-height surface. MobileNav is the
* edge-anchored primitive this actually needs, and its native <dialog>
* brings the focus trap, Escape and backdrop with it.
*
* It renders the SAME <LoyalyAiPanel /> as the inline path — crossing the
* breakpoint changes where the assistant lives, never what it contains or
* remembers, because all state sits in LoyalyAiProvider above the route tree.
*/
const SHEET = [
// The composer must clear the home indicator on a notched phone. env() is
// not expressible as a token and pb-safe is not in core Tailwind, so the
// inset is an arbitrary property on the one surface that docks to the
// bottom edge.
'[&>div]:pb-[env(safe-area-inset-bottom)]',
/*
* Hide MobileNav's own header row.
*
* MobileNav always renders a close button after its `header` slot. With
* ChatHeader inside, the sheet showed TWO X buttons stacked — one in
* MobileNav's empty header row, one in ours — and two rows of chrome above
* the conversation. ChatHeader is the assistant's header at every width, so
* the drawer's own row is removed rather than duplicated.
*/
'[&>div>*:first-child]:hidden',
].join(' ');
/**
* Full-bleed on a phone.
*
* MobileNav's `width` is a MAX-width and it defaults to 320px, so passing
* `undefined` on mobile — as this did before — left a 320px sheet with 70px of
* dashboard showing beside it. A conversation is the whole task on a phone;
* `max-w-none` lets the drawer's own `width: 100vw` fill the screen.
*/
const FULL_BLEED = '[&>div]:max-w-none';
export function LoyalyAiSlideOver() {
const bp = useBreakpoint();
const {isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
return (
<MobileNav
// Explicit, because MobileNav otherwise takes its id from AppShell's
// mobile context — which every MobileNav in the tree shares, so this and
// the navigation drawer would both render the same id, and the menu
// button's aria-controls would resolve to whichever the DOM reached
// first.
id="loyaly-ai-slide-over"
isOpen={isSlideOverOpen}
onOpenChange={setSlideOverOpen}
side="end"
width={420}
label="Loyaly AI"
className={bp === 'mobile' ? `${SHEET} ${FULL_BLEED}` : SHEET}
>
<LoyalyAiPanel onClose={() => setSlideOverOpen(false)} />
</MobileNav>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {isPanelInline, useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {ICONS} from '@/shared/utils/icons';
/**
* One control, two behaviours: above the laptop breakpoint it collapses the
* inline panel to give the workspace full width; below it, it opens the
* slide-over. Both read and write the same provider, so the assistant's
* contents never notice which one is in play.
*/
export function LoyalyAiToggle() {
const bp = useBreakpoint();
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useLoyalyAi();
const inline = isPanelInline(bp);
const isShown = inline ? isOpen : isSlideOverOpen;
return (
<IconButton
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
label={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
tooltip={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
variant="ghost"
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
/>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import {Avatar} from '@astryxdesign/core/Avatar';
import {
ChatMessage as ChatMessageRow,
ChatMessageBubble,
} from '@astryxdesign/core/Chat';
import {VStack} from '@astryxdesign/core/Layout';
import {MessageContent} from './MessageContent';
import {TypingIndicator} from './TypingIndicator';
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
/**
* One turn.
*
* User turns are a filled bubble on the right; assistant turns are `ghost` —
* transparent, full-width, no container. That asymmetry is the single biggest
* reason ChatGPT and Claude read as conversation rather than as messaging: a
* long analytical answer inside a chat bubble reads as a quote, while the same
* text set flush against the panel reads as prose written for you.
*
* It also solves a practical problem. Assistant answers contain tables and
* code blocks, and a bubble with a max-width would force both to scroll
* horizontally inside a 380px panel.
*
* Timestamps are deliberately absent. They are metadata about a chat log, and
* this is a working session — ChatGPT, Claude and Gemini all omit them for the
* same reason.
*/
export function MessageBubble({message}: {message: ChatMessage}) {
const isAssistant = message.role === 'assistant';
// Nothing has arrived yet: the dots stand in for the answer, and are
// replaced by it rather than joined to it.
const isThinking = isAssistant && message.isStreaming && message.parts.length === 0;
return (
<ChatMessageRow
sender={message.role}
density="compact"
avatar={
isAssistant ? (
<Avatar name="Loyaly AI" size="xsm" tooltip={false} />
) : undefined
}
>
<ChatMessageBubble variant={isAssistant ? 'ghost' : 'filled'}>
{isThinking ? (
<TypingIndicator />
) : (
<VStack gap={3}>
{message.parts.map((part, index) => (
<MessageContent
// Index is a stable key here: parts only ever grow at the end,
// and the streaming text part is replaced in place rather than
// reordered — see applyStreamedPart.
key={`${part.type}-${index}`}
part={part}
isStreaming={message.isStreaming}
/>
))}
</VStack>
)}
</ChatMessageBubble>
</ChatMessageRow>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import {Markdown} from '@astryxdesign/core/Markdown';
import {Text} from '@astryxdesign/core/Text';
import {VStack} from '@astryxdesign/core/Layout';
import {ResponseRenderer} from './response/ResponseRenderer';
import type {MessagePart} from '@/features/loyaly-ai/types/chat';
/**
* The renderer registry: one message part in, one block out.
*
* THIS is the extension point the brief asks for. Charts, interactive tables,
* business dashboards, file previews and image responses each become a case
* below plus a variant in types/chat — and nothing else in the module moves,
* because the transport already yields parts and the message list already maps
* over them.
*
* The unimplemented cases are not silently dropped. A part the UI cannot draw
* yet says so, which is the difference between "this version cannot render a
* chart" and "the answer arrived empty".
*/
export function MessageContent({
part,
isStreaming,
}: {
part: MessagePart;
isStreaming?: boolean;
}) {
switch (part.type) {
case 'report':
return <ResponseRenderer report={part} isStreaming={isStreaming} />;
case 'text':
return (
// `isStreaming` lets Markdown tolerate half-finished syntax — a table
// that is three rows in, a code fence with no closing backticks — and
// draws the caret. Without it, every chunk boundary would flash raw
// markup on screen.
<Markdown
density="compact"
isStreaming={isStreaming}
// The panel is 380px wide and lives inside a page that already has
// an h1. Starting at h3 keeps an assistant heading from
// out-ranking the page it is advising on.
headingLevelStart={3}
>
{part.text}
</Markdown>
);
// ── Not yet rendered. Each is a component away, not a refactor away. ──
case 'chart':
case 'table':
case 'file':
case 'image':
return (
<VStack gap={1}>
<Text size="sm" type="supporting">
{`This answer includes a ${part.type} that this version cannot display yet.`}
</Text>
</VStack>
);
}
}

View File

@@ -0,0 +1,44 @@
'use client';
import {ChatMessageList} from '@astryxdesign/core/Chat';
import {MessageBubble} from './MessageBubble';
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
/**
* The transcript.
*
* ── On vertical anchoring ────────────────────────────────────────────────
* ChatMessageList renders a `flex: 1 1 0` spacer before its first message, so
* a short conversation sits at the BOTTOM of the scroll area and grows upward
* — Slack's behaviour rather than ChatGPT's. Measured: one short exchange sat
* 561px down the panel with the whole upper half blank.
*
* TOP_ANCHORED collapses that spacer. It is safe here because Conversation
* owns the scroll container: an earlier attempt with Astryx's ChatLayout
* scrolled a short conversation clean out of view, because that layout's
* auto-scroll assumed the spacer was filling the box.
*
* ChatMessageList is Astryx's own list primitive and it carries the
* behaviour worth not reimplementing: it is an aria-live region, so a screen
* reader announces an answer as it arrives, and `isStreaming` coordinates with
* ChatLayout's auto-scroll so the view follows the text without fighting a
* user who has scrolled up to re-read something.
*/
/** See the anchoring note above. */
const TOP_ANCHORED = '[&>div>div:first-child]:hidden';
export function MessageList({
messages,
isStreaming,
}: {
messages: ChatMessage[];
isStreaming: boolean;
}) {
return (
<ChatMessageList isStreaming={isStreaming} className={TOP_ANCHORED}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</ChatMessageList>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import {useCallback, useEffect, useState} from 'react';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
export function ResizeHandle() {
const {panelWidth, setPanelWidth, panelMode} = useLoyalyAi();
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const newWidth = window.innerWidth - e.clientX;
setPanelWidth(newWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, setPanelWidth]);
// Hidden when in fullscreen mode
if (panelMode === 'fullscreen') return null;
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDoubleClick = () => {
setPanelWidth(440);
};
return (
<div
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
title="Drag to resize panel (Double click to reset)"
className={`absolute left-0 top-0 bottom-0 w-2 -ml-1 cursor-col-resize z-20 group flex justify-center ${
isDragging ? 'select-none' : ''
}`}
>
<div
className={`w-0.5 h-full transition-colors duration-150 ${
isDragging
? 'bg-primary shadow-sm'
: 'bg-transparent group-hover:bg-border-strong'
}`}
/>
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions';
/**
* The opening moves, as a wrapped row of chips.
*
* Chips rather than the stacked button list this replaces: six full-width
* secondary buttons read as a menu of commands, which is what made the old
* panel feel like a dashboard. A wrapped row of quiet pills reads as
* suggestions — the same distinction ChatGPT, Claude and Gemini all make.
*
* Hand-built rather than Astryx's Token: Token is a data chip with a
* dismiss/selected vocabulary that does not apply here, and Button carries a
* control's weight. What is wanted is a quiet, fully-rounded, tappable label —
* three token-backed utilities, and no component contract to fight.
*/
const CHIP = [
'rounded-full border border-border bg-card',
'px-3.5 py-2 text-sm text-secondary text-left',
'transition-colors duration-150 cursor-pointer',
'hover:bg-muted hover:text-primary hover:border-border-strong',
'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
].join(' ');
export function SuggestionChips({onPick}: {onPick: (prompt: string) => void}) {
return (
<HStack gap={2} wrap="wrap" hAlign="center">
{SUGGESTIONS.map((s) => (
<button
key={s.id}
type="button"
className={CHIP}
onClick={() => onPick(s.prompt)}
>
{s.label}
</button>
))}
</HStack>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
/**
* Three dots, while the first token is still on its way.
*
* Shown only BEFORE any text arrives. Once the answer starts streaming, the
* text itself is the progress indicator, and keeping the dots underneath it
* would say "still thinking" about something already being read.
*
* The stagger is an arbitrary-property utility rather than
* `style={{animationDelay}}` — the project bans inline style objects, and an
* animation offset is not a design token, so there is no token-backed utility
* to reach for. One shared keyframe with three offsets keeps the dots in phase
* with each other regardless of when the component mounts.
*
* Reduced motion is handled globally in globals.css; the dots then rest as
* three static marks, which is a fine still frame.
*/
const DOT = 'size-1.5 rounded-full bg-secondary animate-bounce';
export function TypingIndicator() {
return (
<HStack gap={1} vAlign="center">
<span aria-hidden="true" className={`${DOT} [animation-delay:-300ms]`} />
<span aria-hidden="true" className={`${DOT} [animation-delay:-150ms]`} />
<span aria-hidden="true" className={DOT} />
<VisuallyHidden>Loyaly AI is thinking</VisuallyHidden>
</HStack>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {useToast} from '@astryxdesign/core/Toast';
import {useRouter} from 'next/navigation';
import {ICONS} from '@/shared/utils/icons';
import type {ReportAction} from '@/features/loyaly-ai/types/chat';
import {exportData} from '@/shared/utils/export/exportManager';
export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) {
const router = useRouter();
const toast = useToast();
if (!actions || actions.length === 0) return null;
const handleActionClick = async (act: ReportAction) => {
switch (act.actionType) {
case 'open_dashboard':
router.push('/dashboard');
break;
case 'compare_stores':
router.push('/stores');
break;
case 'view_rewards':
router.push('/lyts');
break;
case 'export_csv':
try {
await exportData({
filename: 'Loyaly_AI_Report',
title: 'Loyaly AI Business Report',
subtitle: 'Exported from Loyaly AI Assistant',
columns: [
{key: 'metric', header: 'Metric Name'},
{key: 'val', header: 'Value'},
],
data: [
{metric: 'Daily Revenue', val: '₹3,40,000'},
{metric: 'Visitors', val: '1,284'},
{metric: 'Purchases', val: '276'},
],
format: 'csv',
});
toast({body: '✓ CSV downloaded successfully'});
} catch {
toast({body: '❌ Download failed'});
}
break;
case 'download_report':
try {
await exportData({
filename: 'Loyaly_AI_Report',
title: 'Loyaly AI Business Report',
subtitle: 'Exported from Loyaly AI Assistant',
columns: [
{key: 'metric', header: 'Metric Name'},
{key: 'val', header: 'Value'},
],
data: [
{metric: 'Daily Revenue', val: '₹3,40,000'},
{metric: 'Visitors', val: '1,284'},
{metric: 'Purchases', val: '276'},
],
format: 'pdf',
});
toast({body: '✓ PDF downloaded successfully'});
} catch {
toast({body: '❌ Download failed'});
}
break;
default:
toast({body: `Executed: ${act.label}`});
break;
}
};
return (
<div className="pt-2 flex flex-wrap items-center gap-2 w-full">
{actions.map((act) => (
<Button
key={act.id || act.label}
size="sm"
variant="secondary"
label={act.label}
icon={
act.iconName ? (
<Icon icon={(ICONS as any)[act.iconName] || 'externalLink'} size="sm" />
) : undefined
}
onClick={() => handleActionClick(act)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import {Heading} from '@astryxdesign/core/Text';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import type {ChartPartSpec} from '@/features/loyaly-ai/types/chat';
export function ChartBlock({charts}: {charts: ChartPartSpec[]}) {
if (!charts || charts.length === 0) return null;
return (
<div className="space-y-4 w-full">
{charts.map((c, idx) => (
<div
key={c.title || idx}
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3"
>
<div>
<Heading level={4} className="text-sm font-semibold text-primary">
{c.title}
</Heading>
{c.subtitle ? (
<p className="text-xs text-secondary">{c.subtitle}</p>
) : null}
</div>
<div className="h-48 sm:h-56 w-full pt-2">
{c.chartType === 'area' ? (
<AreaChartView
data={c.data}
xKey={c.xKey}
series={c.series}
/>
) : c.chartType === 'bar' ? (
<BarChartView
data={c.data}
xKey={c.xKey}
series={c.series}
/>
) : (
<LineChartView
data={c.data}
xKey={c.xKey}
series={c.series}
/>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import {Badge} from '@astryxdesign/core/Badge';
import type {ReportInsight} from '@/features/loyaly-ai/types/chat';
export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
if (!insights || insights.length === 0) return null;
const getInsightMeta = (type: ReportInsight['type']) => {
switch (type) {
case 'opportunity':
return {
icon: '💡',
badgeVariant: 'info' as const,
label: 'Opportunity',
borderClass: 'border-blue-500/30 bg-blue-500/[0.03]',
};
case 'risk':
return {
icon: '⚠️',
badgeVariant: 'warning' as const,
label: 'Risk Alert',
borderClass: 'border-amber-500/30 bg-amber-500/[0.03]',
};
case 'growth':
return {
icon: '📈',
badgeVariant: 'success' as const,
label: 'Growth Factor',
borderClass: 'border-emerald-500/30 bg-emerald-500/[0.03]',
};
case 'best_performer':
return {
icon: '🏆',
badgeVariant: 'success' as const,
label: 'Top Performer',
borderClass: 'border-purple-500/30 bg-purple-500/[0.03]',
};
}
};
return (
<div className="space-y-3 w-full">
{insights.map((insight) => {
const meta = getInsightMeta(insight.type);
return (
<div
key={insight.id || insight.title}
className={`rounded-xl border p-4 shadow-sm space-y-2 transition-colors ${meta.borderClass}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-base">{meta.icon}</span>
<span className="text-sm font-semibold text-primary">
{insight.title}
</span>
</div>
<div className="flex items-center gap-2">
{insight.confidence ? (
<span className="text-xs text-secondary font-mono">
{insight.confidence}% confidence
</span>
) : null}
<Badge variant={meta.badgeVariant} label={meta.label} />
</div>
</div>
<p className="text-xs text-secondary leading-relaxed">
{insight.explanation}
</p>
{insight.recommendedAction ? (
<div className="pt-1.5 flex items-center gap-2 text-xs text-primary font-medium">
<span className="text-secondary font-normal">Action:</span>
<span>{insight.recommendedAction}</span>
</div>
) : null}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import {Icon} from '@astryxdesign/core/Icon';
import type {ReportKpi} from '@/features/loyaly-ai/types/chat';
function MiniSparkline({data}: {data: number[]}) {
if (!data || data.length < 2) return null;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const width = 80;
const height = 24;
const points = data
.map((val, idx) => {
const x = (idx / (data.length - 1)) * width;
const y = height - ((val - min) / range) * (height - 4) - 2;
return `${x},${y}`;
})
.join(' ');
return (
<svg width={width} height={height} className="overflow-visible">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
className="text-primary/70"
/>
</svg>
);
}
export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
if (!kpis || kpis.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full">
{kpis.map((kpi) => {
const isUp = kpi.trendDirection === 'up' || (kpi.trend && kpi.trend.startsWith('+'));
const isDown = kpi.trendDirection === 'down' || (kpi.trend && (kpi.trend.startsWith('-') || kpi.trend.startsWith('')));
return (
<div
key={kpi.id || kpi.title}
className="rounded-xl border border-border bg-card p-3.5 flex flex-col justify-between shadow-sm hover:border-border-strong transition-colors"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-secondary truncate">
{kpi.title}
</span>
{kpi.iconName ? (
<Icon icon={kpi.iconName as any} size="xsm" color="secondary" />
) : null}
</div>
<div className="mt-2 flex items-baseline justify-between gap-2">
<span className="text-lg sm:text-xl font-semibold tracking-tight text-primary">
{kpi.value}
</span>
{kpi.trend ? (
<span
className={`text-xs font-medium ${
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
}`}
>
{kpi.trend}
</span>
) : null}
</div>
{kpi.sparkline ? (
<div className="mt-2 pt-1 flex justify-end">
<MiniSparkline data={kpi.sparkline} />
</div>
) : null}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import {useState} from 'react';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {useToast} from '@astryxdesign/core/Toast';
import {ICONS} from '@/shared/utils/icons';
import {exportData} from '@/shared/utils/export/exportManager';
export function MessageToolbarBlock({
textToCopy,
onRegenerate,
}: {
textToCopy?: string;
onRegenerate?: () => void;
}) {
const toast = useToast();
const [copied, setCopied] = useState(false);
const [liked, setLiked] = useState<boolean | null>(null);
const handleCopy = () => {
if (textToCopy) {
navigator.clipboard.writeText(textToCopy);
setCopied(true);
toast({body: 'Response copied to clipboard'});
setTimeout(() => setCopied(false), 2000);
}
};
const handleExportPdf = async () => {
try {
await exportData({
filename: 'Loyaly_AI_Response',
title: 'Loyaly AI Assistant Response Report',
subtitle: 'Exported from Loyaly AI Assistant',
columns: [
{key: 'content', header: 'AI Response Summary'},
],
data: [
{content: textToCopy || 'Loyaly AI Business Assistant Response'},
],
format: 'pdf',
});
toast({body: '✓ PDF downloaded successfully'});
} catch {
toast({body: '❌ Download failed'});
}
};
const handleShare = () => {
toast({body: 'Share link generated and copied'});
};
return (
<div className="pt-3 border-t border-border/40 flex items-center justify-between gap-2 text-secondary w-full">
<div className="flex items-center gap-1">
<IconButton
variant="ghost"
size="sm"
label={copied ? 'Copied' : 'Copy'}
tooltip={copied ? 'Copied' : 'Copy response'}
icon={<Icon icon={copied ? 'check' : 'copy'} size="xsm" />}
onClick={handleCopy}
/>
<IconButton
variant="ghost"
size="sm"
label="Helpful"
tooltip="Helpful"
icon={
<Icon
icon="arrowUp"
size="xsm"
color={liked === true ? 'primary' : 'secondary'}
/>
}
onClick={() => {
setLiked(true);
toast({body: 'Thank you for your feedback!'});
}}
/>
<IconButton
variant="ghost"
size="sm"
label="Unhelpful"
tooltip="Unhelpful"
icon={
<Icon
icon="arrowDown"
size="xsm"
color={liked === false ? 'primary' : 'secondary'}
/>
}
onClick={() => {
setLiked(false);
toast({body: 'Feedback submitted. We will improve.'});
}}
/>
{onRegenerate ? (
<IconButton
variant="ghost"
size="sm"
label="Regenerate"
tooltip="Regenerate"
icon={<Icon icon={ICONS.compare} size="xsm" />}
onClick={onRegenerate}
/>
) : null}
</div>
<div className="flex items-center gap-1">
<IconButton
variant="ghost"
size="sm"
label="Export PDF"
tooltip="Export PDF"
icon={<Icon icon={ICONS.download} size="xsm" />}
onClick={handleExportPdf}
/>
<IconButton
variant="ghost"
size="sm"
label="Share"
tooltip="Share response"
icon={<Icon icon="externalLink" size="xsm" />}
onClick={handleShare}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import {SummaryCard} from './SummaryCard';
import {KpiGridBlock} from './KpiGridBlock';
import {ChartBlock} from './ChartBlock';
import {TableBlock} from './TableBlock';
import {InsightCardsBlock} from './InsightCardsBlock';
import {ActionToolbarBlock} from './ActionToolbarBlock';
import {MessageToolbarBlock} from './MessageToolbarBlock';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function ResponseRenderer({
report,
isStreaming,
}: {
report: ReportPart;
isStreaming?: boolean;
}) {
const textContent = report.summary || report.title || '';
return (
<div className="space-y-4 w-full my-1">
{/* 1. Summary / Title */}
{report.summary ? (
<SummaryCard
title={report.title}
summary={report.summary}
isStreaming={isStreaming}
/>
) : null}
{/* 2. KPI Cards Grid */}
{report.kpis && report.kpis.length > 0 ? (
<KpiGridBlock kpis={report.kpis} />
) : null}
{/* 3. Dynamic Charts */}
{report.charts && report.charts.length > 0 ? (
<ChartBlock charts={report.charts} />
) : null}
{/* 4. Interactive Data Tables */}
{report.tables && report.tables.length > 0 ? (
<TableBlock tables={report.tables} />
) : null}
{/* 5. AI Insight Cards */}
{report.insights && report.insights.length > 0 ? (
<InsightCardsBlock insights={report.insights} />
) : null}
{/* 6. Contextual Action Buttons */}
{report.actions && report.actions.length > 0 ? (
<ActionToolbarBlock actions={report.actions} />
) : null}
{/* 7. Bottom Message Toolbar (Copy, Like/Dislike, Export, Share) */}
{!isStreaming ? (
<MessageToolbarBlock textToCopy={textContent} />
) : null}
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import {Markdown} from '@astryxdesign/core/Markdown';
import {Heading} from '@astryxdesign/core/Text';
export function SummaryCard({
title,
summary,
isStreaming,
}: {
title?: string;
summary: string;
isStreaming?: boolean;
}) {
return (
<div className="rounded-xl border border-border bg-card p-4 sm:p-5 shadow-sm space-y-3">
{title ? (
<Heading level={3} className="text-base font-semibold text-primary">
{title}
</Heading>
) : null}
<div className="text-sm text-primary leading-relaxed">
<Markdown density="compact" isStreaming={isStreaming} headingLevelStart={4}>
{summary}
</Markdown>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import {Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import type {TablePartSpec} from '@/features/loyaly-ai/types/chat';
export function TableBlock({tables}: {tables: TablePartSpec[]}) {
if (!tables || tables.length === 0) return null;
return (
<div className="space-y-4 w-full">
{tables.map((t, idx) => (
<div
key={t.title || idx}
className="rounded-xl border border-border bg-card overflow-hidden shadow-sm"
>
{t.title ? (
<div className="p-4 border-b border-border bg-card">
<Heading level={4} className="text-sm font-semibold text-primary">
{t.title}
</Heading>
</div>
) : null}
<div className="overflow-x-auto w-full">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border bg-muted/40 text-secondary font-medium">
{t.columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 ${
col.align === 'end'
? 'text-right'
: col.align === 'center'
? 'text-center'
: 'text-left'
}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{t.rows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-white/[0.02] transition-colors">
{t.columns.map((col) => {
const val = row[col.key];
const valStr = String(val ?? '');
const isStatus = col.key === 'status' || col.key === 'state';
return (
<td
key={col.key}
className={`px-4 py-3 text-primary ${
col.align === 'end'
? 'text-right'
: col.align === 'center'
? 'text-center'
: 'text-left'
}`}
>
{isStatus ? (
<Badge
variant={
valStr.toLowerCase() === 'active' || valStr.toLowerCase() === 'paid'
? 'success'
: valStr.toLowerCase() === 'warning' || valStr.toLowerCase() === 'at risk'
? 'warning'
: 'neutral'
}
label={valStr}
/>
) : (
valStr
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,319 @@
import type {MessagePart} from '@/features/loyaly-ai/types/chat';
/**
* Stand-in assistant answers, until the model endpoint exists.
*
* Every reply is markdown that exercises a different renderer path — a table,
* a list, a code block, plain prose — because a chat surface that has only
* ever rendered one paragraph is a chat surface whose table styling is
* untested. These are fixtures in the same sense as the dashboard's: server
* -side, replaced wholesale by the real thing, and never imported by a
* component.
*/
interface ReplyTemplate {
/** Lowercased substrings that select this reply. */
match: string[];
parts: MessagePart[];
}
const REPLIES: ReplyTemplate[] = [
{
match: ['report', 'today', 'summary', 'sales'],
parts: [
{
type: 'report',
title: "Today's Business Performance Report",
summary:
"Network total revenue reached **₹3.4L** today (+2.8% vs yesterday). **Indiranagar Flagship** led footfall conversion at 23.8%, while **Whitefield Main** experienced an evening conversion slowdown due to staff coverage.",
kpis: [
{
id: 'rev',
title: 'Daily Revenue',
value: '₹3,40,000',
trend: '+2.8%',
trendDirection: 'up',
iconName: 'revenue',
sparkline: [2.8, 3.1, 2.9, 3.2, 3.4],
},
{
id: 'footfall',
title: 'Total Visitors',
value: '1,284',
trend: '+4.2%',
trendDirection: 'up',
iconName: 'visitors',
sparkline: [1150, 1200, 1180, 1240, 1284],
},
{
id: 'orders',
title: 'Completed Orders',
value: '276',
trend: '-1.1%',
trendDirection: 'down',
iconName: 'purchases',
sparkline: [290, 285, 280, 278, 276],
},
{
id: 'lyt',
title: 'LYTs Issued',
value: '34,100',
trend: '+6.0%',
trendDirection: 'up',
iconName: 'lyt',
sparkline: [28000, 30000, 31500, 32000, 34100],
},
],
charts: [
{
title: 'Intraday Revenue & Visitors',
subtitle: 'Hourly performance across all active outlets',
chartType: 'area',
xKey: 'time',
data: [
{time: '10:00', Revenue: 25000, Visitors: 120},
{time: '12:00', Revenue: 62000, Visitors: 240},
{time: '14:00', Revenue: 85000, Visitors: 310},
{time: '16:00', Revenue: 78000, Visitors: 280},
{time: '18:00', Revenue: 90000, Visitors: 334},
],
series: [
{key: 'Revenue', label: 'Revenue (₹)'},
{key: 'Visitors', label: 'Footfall'},
],
},
],
insights: [
{
id: 'i1',
type: 'growth',
title: 'Flagship Store Outperformance',
explanation:
'Indiranagar Flagship generated 41% of network daily revenue on 28% of overall footfall.',
confidence: 96,
recommendedAction: 'Replicate flagship evening promotion strategy across Koramangala.',
},
{
id: 'i2',
type: 'opportunity',
title: 'Evening Shift Conversion Gap',
explanation:
'Conversion rate dropped 4.2% after 18:00 IST at Whitefield Main due to roster gaps.',
confidence: 91,
recommendedAction: 'Adjust floor staff roster for 18:00 - 21:00 peak hours.',
},
],
actions: [
{id: 'a1', label: 'Open Full Dashboard', actionType: 'open_dashboard', iconName: 'dashboard'},
{id: 'a2', label: 'Compare Outlet Performance', actionType: 'compare_stores', iconName: 'stores'},
{id: 'a3', label: 'Export Report CSV', actionType: 'export_csv', iconName: 'download'},
],
},
],
},
{
match: ['compare', 'store', 'outlet'],
parts: [
{
type: 'report',
title: 'Multi-Store Network Comparison (30 Days)',
summary:
'Comparison of 4 active outlets over the last 30 days. **Indiranagar Flagship** leads across all key metrics, while **Whitefield Main** shows significant conversion upside potential.',
kpis: [
{id: 'c1', title: 'Top Store Revenue', value: '₹9.1L', trend: 'Indiranagar', iconName: 'stores'},
{id: 'c2', title: 'Network Average Conversion', value: '21.1%', trend: '+1.4%', iconName: 'conversion'},
{id: 'c3', title: 'Outlets Tracked', value: '4 Active', iconName: 'stores'},
],
tables: [
{
title: 'Store Performance Breakdown',
columns: [
{key: 'store', header: 'Store Location'},
{key: 'visitors', header: 'Visitors', align: 'end'},
{key: 'revenue', header: 'Revenue', align: 'end'},
{key: 'conversion', header: 'Conversion', align: 'end'},
{key: 'status', header: 'Status', align: 'center'},
],
rows: [
{store: 'Indiranagar Flagship', visitors: '12,400', revenue: '₹9,10,000', conversion: '23.8%', status: 'active'},
{store: 'Koramangala 80ft', visitors: '7,800', revenue: '₹5,20,000', conversion: '21.4%', status: 'active'},
{store: 'Jayanagar 4th Block', visitors: '6,100', revenue: '₹4,00,000', conversion: '20.9%', status: 'active'},
{store: 'Whitefield Main', visitors: '4,200', revenue: '₹3,30,000', conversion: '18.2%', status: 'maintenance'},
],
},
],
insights: [
{
id: 'ci1',
type: 'best_performer',
title: 'Indiranagar Leading Network',
explanation: '23.8% conversion rate exceeds network benchmark by 2.7 percentage points.',
confidence: 98,
},
{
id: 'ci2',
type: 'risk',
title: 'Whitefield Conversion Underperformance',
explanation: 'Whitefield is 5.6 points behind flagship despite high visitor traffic per staff seat.',
confidence: 89,
recommendedAction: 'Conduct staff training on checkout upsell workflows.',
},
],
actions: [
{id: 'ca1', label: 'View Stores Directory', actionType: 'compare_stores', iconName: 'stores'},
{id: 'ca2', label: 'Download Comparison Data', actionType: 'export_csv', iconName: 'download'},
],
},
],
},
{
match: ['reward', 'lyt', 'redeem', 'loyalty'],
parts: [
{
type: 'report',
title: 'Rewards & LYT Liability Performance',
summary:
'Analysis of active merchant reward campaigns. High claim rates on BOGO create unredeemed liability, while **Weekend Double LYTs** drives the highest actual redemption velocity.',
kpis: [
{id: 'r1', title: 'Total Claims', value: '1,442', trend: '+14%', iconName: 'lyt'},
{id: 'r2', title: 'Redemption Rate', value: '54.2%', trend: '+3.1%', iconName: 'conversion'},
{id: 'r3', title: 'Outstanding LYTs', value: '1.2L LYTs', trend: '₹1.2L liability', iconName: 'lyt'},
],
tables: [
{
title: 'Campaign Performance Summary',
columns: [
{key: 'campaign', header: 'Reward Campaign'},
{key: 'claims', header: 'Total Claims', align: 'end'},
{key: 'redemption', header: 'Redemption Rate', align: 'end'},
{key: 'status', header: 'Status', align: 'center'},
],
rows: [
{campaign: 'Buy 1 Get 1 Free', claims: '688', redemption: '19.0%', status: 'warning'},
{campaign: 'Combo 20% Off', claims: '420', redemption: '61.0%', status: 'active'},
{campaign: 'Weekend Double LYTs', claims: '334', redemption: '74.0%', status: 'active'},
],
},
],
insights: [
{
id: 'ri1',
type: 'opportunity',
title: 'Shorten BOGO Redemption Window',
explanation:
'81% of BOGO claims remain unredeemed after 14 days, creating unfulfilled customer expectations and pending liability.',
confidence: 94,
recommendedAction: 'Reduce expiry window from 14 days to 5 days to trigger immediate store visits.',
},
],
actions: [
{id: 'ra1', label: 'Manage Rewards & LYTs', actionType: 'view_rewards', iconName: 'lyts'},
{id: 'ra2', label: 'Export Redemption Log', actionType: 'export_csv', iconName: 'download'},
],
},
],
},
{
match: ['staff', 'team', 'attendance', 'roster'],
parts: [
{
type: 'report',
title: 'Staff Roster & Productivity Leaderboard',
summary:
'Current roster status: **35 of 49** staff active on floor. **Sneha R** leads network productivity with 312 completed transactions and a 94 performance score.',
kpis: [
{id: 'st1', title: 'Active Floor Staff', value: '35 / 49', trend: '14 Absent', iconName: 'staff'},
{id: 'st2', title: 'Network Avg Score', value: '88.4', trend: '+2.1', iconName: 'leaderboard'},
{id: 'st3', title: 'Total Shift Txns', value: '1,240', iconName: 'purchases'},
],
tables: [
{
title: 'Top Staff Leaderboard',
columns: [
{key: 'name', header: 'Staff Member'},
{key: 'store', header: 'Assigned Outlet'},
{key: 'txns', header: 'Transactions', align: 'end'},
{key: 'score', header: 'Score', align: 'end'},
{key: 'status', header: 'Shift Status', align: 'center'},
],
rows: [
{name: 'Sneha R', store: 'Indiranagar Flagship', txns: '312', score: '94', status: 'active'},
{name: 'Rohit K', store: 'Koramangala 80ft', txns: '287', score: '91', status: 'active'},
{name: 'Ravi M', store: 'Jayanagar 4th Block', txns: '264', score: '88', status: 'active'},
{name: 'Priya Sharma', store: 'Whitefield Main', txns: '198', score: '82', status: 'active'},
],
},
],
insights: [
{
id: 'sti1',
type: 'risk',
title: 'Whitefield Roster Shortage',
explanation: 'Absence is concentrated at Whitefield Main (4 staff members missing during peak hours).',
confidence: 93,
recommendedAction: 'Reassign 2 floaters from Koramangala shift roster.',
},
],
actions: [
{id: 'sta1', label: 'Open Staff Roster', actionType: 'open_dashboard', iconName: 'staff'},
],
},
],
},
{
match: ['customer', 'retention', 'churn'],
parts: [
{
type: 'report',
title: 'Customer Retention & Cohort Analytics',
summary:
'Retention rate sits at **38% repeat customers** over 90 days with a median visit cycle of 17 days. 1,240 previously active monthly buyers are currently flagged at-risk.',
kpis: [
{id: 'ret1', title: '90-Day Repeat Rate', value: '38.0%', trend: '+1.8%', iconName: 'conversion'},
{id: 'ret2', title: 'Median Visit Gap', value: '17 Days', trend: '-2 Days', iconName: 'sessions'},
{id: 'ret3', title: 'At-Risk Customers', value: '1,240', trend: '₹2.1L Prior Spend', iconName: 'alert'},
],
insights: [
{
id: 'reti1',
type: 'opportunity',
title: 'At-Risk Customer Winback Campaign',
explanation:
'1,240 customers who spent ₹2.1L have not visited in 45+ days. A targeted 100-LYT winback bonus will recover an estimated ₹85,000 in revenue.',
confidence: 95,
recommendedAction: 'Launch Automated Winback Campaign in LYT Rewards Manager.',
},
],
actions: [
{id: 'reta1', label: 'View Rewards Manager', actionType: 'view_rewards', iconName: 'lyts'},
{id: 'reta2', label: 'Export At-Risk Cohort CSV', actionType: 'export_csv', iconName: 'download'},
],
},
],
},
];
const FALLBACK: MessagePart[] = [
{
type: 'report',
title: 'Loyaly AI Assistant Overview',
summary:
'I can generate rich business reports and insights for your **stores, sales, customer retention, staff performance, and rewards**.',
kpis: [
{id: 'fb1', title: 'Network Outlets', value: '4 Active', iconName: 'stores'},
{id: 'fb2', title: 'Monthly Revenue', value: '₹14.99L', trend: '+4.8%', iconName: 'revenue'},
{id: 'fb3', title: 'Total Customers', value: '14,250', iconName: 'visitors'},
],
actions: [
{id: 'fba1', label: 'Open Main Dashboard', actionType: 'open_dashboard', iconName: 'dashboard'},
{id: 'fba2', label: 'Compare Stores', actionType: 'compare_stores', iconName: 'stores'},
],
},
];
/** Pick the reply whose keywords the prompt matches, else the fallback. */
export function replyFor(prompt: string): MessagePart[] {
const q = prompt.toLowerCase();
const hit = REPLIES.find((r) => r.match.some((m) => q.includes(m)));
return hit ? hit.parts : FALLBACK;
}

View File

@@ -0,0 +1,340 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {loyalyAiRepository} from '@/features/loyaly-ai/repositories/loyalyAiRepository';
import {
applyStreamedPart,
createConversation,
createMessage,
titleFromPrompt,
toHistory,
} from '@/features/loyaly-ai/services/loyalyAiService';
import type {
ChatMessage,
Conversation,
ConversationSummary,
} from '@/features/loyaly-ai/types/chat';
/**
* All Loyaly AI state, mounted in app/providers.tsx — ABOVE the route tree.
*
* Two things force that placement:
* 1. Route changes. The panel lives in the workspace shell, which the App
* Router preserves across sibling navigations, so it would survive
* /dashboard → /staff on its own.
* 2. The tablet breakpoint. Crossing it swaps the inline panel for a
* slide-over, which genuinely unmounts the surface. Holding state up here
* means the presentation can change without losing a conversation, a
* half-typed message, or a stream in flight.
*
* Every component below is a pure view over this context.
*/
export type PanelMode = 'normal' | 'expanded' | 'fullscreen';
interface LoyalyAiValue {
/** Inline panel visibility, above the laptop breakpoint. */
isOpen: boolean;
setIsOpen: (v: boolean) => void;
toggle: () => void;
/** Slide-over visibility, below it. */
isSlideOverOpen: boolean;
setSlideOverOpen: (v: boolean) => void;
panelMode: PanelMode;
setPanelMode: (mode: PanelMode) => void;
toggleExpand: () => void;
toggleFullscreen: () => void;
panelWidth: number;
setPanelWidth: (w: number) => void;
conversation: Conversation;
history: ConversationSummary[];
isHistoryOpen: boolean;
setHistoryOpen: (v: boolean) => void;
/** The composer value. Kept here so navigation never drops it. */
draft: string;
setDraft: (v: string) => void;
send: (text: string) => void;
stop: () => void;
isStreaming: boolean;
newChat: () => void;
openConversation: (id: string) => void;
}
const LoyalyAiContext = createContext<LoyalyAiValue | null>(null);
const STORAGE_MODE_KEY = 'loyaly_ai_panel_mode';
const STORAGE_WIDTH_KEY = 'loyaly_ai_panel_width';
export function LoyalyAiProvider({children}: {children: React.ReactNode}) {
const [isOpen, setIsOpen] = useState(true);
const [isSlideOverOpen, setSlideOverOpen] = useState(false);
const [isHistoryOpen, setHistoryOpen] = useState(false);
const [draft, setDraft] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [panelMode, setPanelModeState] = useState<PanelMode>('normal');
const [panelWidth, setPanelWidthState] = useState<number>(440);
// Sync saved localStorage settings post-hydration to eliminate SSR mismatch
useEffect(() => {
const savedMode = localStorage.getItem(STORAGE_MODE_KEY);
if (savedMode === 'expanded' || savedMode === 'fullscreen' || savedMode === 'normal') {
setPanelModeState(savedMode as PanelMode);
}
const savedWidth = localStorage.getItem(STORAGE_WIDTH_KEY);
if (savedWidth) {
const parsed = parseInt(savedWidth, 10);
if (!isNaN(parsed) && parsed >= 420 && parsed <= 1800) {
setPanelWidthState(parsed);
}
}
}, []);
const prevModeRef = useRef<PanelMode>('normal');
const setPanelMode = useCallback((mode: PanelMode) => {
setPanelModeState((prev) => {
prevModeRef.current = prev;
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_MODE_KEY, mode);
}
return mode;
});
}, []);
const setPanelWidth = useCallback((w: number) => {
const clamped = Math.max(420, Math.min(w, typeof window !== 'undefined' ? window.innerWidth * 0.85 : 1400));
setPanelWidthState(clamped);
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_WIDTH_KEY, clamped.toString());
}
}, []);
const toggleExpand = useCallback(() => {
setPanelModeState((prev) => {
const next = prev === 'expanded' ? 'normal' : 'expanded';
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_MODE_KEY, next);
}
return next;
});
}, []);
const toggleFullscreen = useCallback(() => {
setPanelModeState((prev) => {
const next = prev === 'fullscreen' ? (prevModeRef.current === 'fullscreen' ? 'normal' : prevModeRef.current) : 'fullscreen';
prevModeRef.current = prev;
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_MODE_KEY, next);
}
return next;
});
}, []);
// Every conversation this session, newest state included. The active one is
// referenced by id rather than held separately, so there is no way for the
// list and the open chat to disagree about the same conversation.
const [conversations, setConversations] = useState<Conversation[]>(() => [
createConversation(),
]);
const [activeId, setActiveId] = useState<string>(
() => conversations[0]?.id ?? '',
);
// Aborts the stream in flight. A ref because it is machinery, not state:
// nothing renders differently because a controller exists.
const abortRef = useRef<AbortController | null>(null);
const conversation = useMemo(
() => conversations.find((c) => c.id === activeId) ?? conversations[0],
[conversations, activeId],
);
const history = useMemo(() => toHistory(conversations), [conversations]);
/** Update one conversation in place, stamping updatedAt. */
const patchConversation = useCallback(
(id: string, update: (c: Conversation) => Conversation) => {
setConversations((prev) =>
prev.map((c) =>
c.id === id ? {...update(c), updatedAt: new Date().toISOString()} : c,
),
);
},
[],
);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
// Clear the streaming flag on whatever was being written, or the cursor
// blinks forever on an answer that stopped arriving.
setConversations((prev) =>
prev.map((c) => ({
...c,
messages: c.messages.map((m) =>
m.isStreaming ? {...m, isStreaming: false} : m,
),
})),
);
}, []);
const send = useCallback(
(text: string) => {
const prompt = text.trim();
if (!prompt || isStreaming) return;
const targetId = conversation.id;
const userMessage = createMessage('user', [{type: 'text', text: prompt}]);
const assistantMessage = createMessage('assistant', [], {
isStreaming: true,
});
patchConversation(targetId, (c) => ({
...c,
// Named from the first prompt, so history is readable without opening
// anything. Later prompts do not rename it — a conversation that
// renamed itself as it went would be impossible to find again.
title: c.messages.length === 0 ? titleFromPrompt(prompt) : c.title,
messages: [...c.messages, userMessage, assistantMessage],
}));
setDraft('');
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
void (async () => {
try {
const stream = loyalyAiRepository.streamReply({
prompt,
history: conversation.messages,
signal: controller.signal,
});
for await (const part of stream) {
patchConversation(targetId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantMessage.id ? applyStreamedPart(m, part) : m,
),
}));
}
} finally {
// Runs on success, on abort and on failure. Whatever happened, the
// message must stop claiming to be mid-stream.
if (abortRef.current === controller) abortRef.current = null;
patchConversation(targetId, (c) => ({
...c,
messages: c.messages.map((m) =>
m.id === assistantMessage.id ? {...m, isStreaming: false} : m,
),
}));
setIsStreaming(false);
}
})();
},
[conversation, isStreaming, patchConversation],
);
const newChat = useCallback(() => {
stop();
setConversations((prev) => {
// Reuse the current one when it is already empty. Otherwise every click
// of New chat strands another blank conversation in memory.
const current = prev.find((c) => c.id === activeId);
if (current && current.messages.length === 0) return prev;
const fresh = createConversation();
setActiveId(fresh.id);
return [fresh, ...prev];
});
setDraft('');
setHistoryOpen(false);
}, [activeId, stop]);
const openConversation = useCallback(
(id: string) => {
stop();
setActiveId(id);
setHistoryOpen(false);
},
[stop],
);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
const value = useMemo<LoyalyAiValue>(
() => ({
isOpen,
setIsOpen,
toggle,
isSlideOverOpen,
setSlideOverOpen,
panelMode,
setPanelMode,
toggleExpand,
toggleFullscreen,
panelWidth,
setPanelWidth,
conversation,
history,
isHistoryOpen,
setHistoryOpen,
draft,
setDraft,
send,
stop,
isStreaming,
newChat,
openConversation,
}),
[
isOpen,
toggle,
isSlideOverOpen,
panelMode,
setPanelMode,
toggleExpand,
toggleFullscreen,
panelWidth,
setPanelWidth,
conversation,
history,
isHistoryOpen,
draft,
send,
stop,
isStreaming,
newChat,
openConversation,
],
);
return (
<LoyalyAiContext value={value}>{children}</LoyalyAiContext>
);
}
export function useLoyalyAi(): LoyalyAiValue {
const ctx = useContext(LoyalyAiContext);
if (!ctx) {
throw new Error('useLoyalyAi must be used inside <LoyalyAiProvider>');
}
return ctx;
}
/** Re-exported so components do not reach past the provider for a type. */
export type {ChatMessage};

View File

@@ -0,0 +1,24 @@
import {streamDynamicAiResponse} from '@/features/loyaly-ai/services/ai/mockAi';
import type {ChatMessage, MessagePart} from '@/features/loyaly-ai/types/chat';
export interface StreamRequest {
prompt: string;
history: ChatMessage[];
signal?: AbortSignal;
}
export const loyalyAiRepository = {
/**
* Stream dynamic assistant answers based on prompt classification & entity routing.
*/
async *streamReply({
prompt,
history,
signal,
}: StreamRequest): AsyncGenerator<MessagePart, void, undefined> {
for await (const part of streamDynamicAiResponse(prompt, history, signal)) {
if (signal?.aborted) return;
yield part;
}
},
};

View File

@@ -0,0 +1,83 @@
import {routePrompt} from './promptRouter';
import {generateReportResponse} from './responseGenerator';
import type {ChatMessage, MessagePart, ReportPart} from '@/features/loyaly-ai/types/chat';
const CHUNK_DELAY_MS = 25;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function* streamDynamicAiResponse(
prompt: string,
_history: ChatMessage[],
signal?: AbortSignal
): AsyncGenerator<MessagePart, void, undefined> {
// 1. Brief thinking delay
await sleep(350);
if (signal?.aborted) return;
// 2. Classify intent & extract entities
const route = routePrompt(prompt);
const fullReport = generateReportResponse(route);
// 3. Progressive streaming simulation (Summary -> KPIs -> Charts -> Tables -> Insights -> Actions)
// Step A: Stream Summary text first
if (fullReport.summary) {
const words = fullReport.summary.split(' ');
let textChunk = '';
for (let i = 0; i < words.length; i += 3) {
if (signal?.aborted) return;
textChunk += (textChunk ? ' ' : '') + words.slice(i, i + 3).join(' ');
yield {
type: 'report',
title: fullReport.title,
summary: textChunk,
} as ReportPart;
await sleep(CHUNK_DELAY_MS);
}
}
// Step B: Stream KPI cards
if (fullReport.kpis && fullReport.kpis.length > 0) {
if (signal?.aborted) return;
yield {
type: 'report',
title: fullReport.title,
summary: fullReport.summary,
kpis: fullReport.kpis,
} as ReportPart;
await sleep(200);
}
// Step C: Stream Charts
if (fullReport.charts && fullReport.charts.length > 0) {
if (signal?.aborted) return;
yield {
type: 'report',
title: fullReport.title,
summary: fullReport.summary,
kpis: fullReport.kpis,
charts: fullReport.charts,
} as ReportPart;
await sleep(200);
}
// Step D: Stream Tables
if (fullReport.tables && fullReport.tables.length > 0) {
if (signal?.aborted) return;
yield {
type: 'report',
title: fullReport.title,
summary: fullReport.summary,
kpis: fullReport.kpis,
charts: fullReport.charts,
tables: fullReport.tables,
} as ReportPart;
await sleep(200);
}
// Step E: Stream Insights & Actions (Final complete report)
if (signal?.aborted) return;
yield fullReport;
}

View File

@@ -0,0 +1,131 @@
export type IntentType =
| 'sales'
| 'stores'
| 'rewards'
| 'staff'
| 'inventory'
| 'retention'
| 'billing'
| 'security'
| 'analytics'
| 'general';
export interface ExtractedEntities {
stores: string[];
metrics: string[];
timeframe: string;
query: string;
}
export interface RouteResult {
intent: IntentType;
entities: ExtractedEntities;
}
export function routePrompt(prompt: string): RouteResult {
const q = prompt.toLowerCase().trim();
const storesFound: string[] = [];
if (q.includes('indiranagar')) storesFound.push('Indiranagar Flagship');
if (q.includes('koramangala')) storesFound.push('Koramangala 80ft');
if (q.includes('jayanagar')) storesFound.push('Jayanagar 4th Block');
if (q.includes('whitefield')) storesFound.push('Whitefield Main');
const metricsFound: string[] = [];
if (q.includes('revenue') || q.includes('sales') || q.includes('earning')) metricsFound.push('Revenue');
if (q.includes('visitor') || q.includes('footfall') || q.includes('traffic')) metricsFound.push('Footfall');
if (q.includes('conversion') || q.includes('rate')) metricsFound.push('Conversion');
if (q.includes('reward') || q.includes('lyt') || q.includes('points')) metricsFound.push('LYTs');
let timeframe = 'today';
if (q.includes('month') || q.includes('30 day')) timeframe = '30d';
if (q.includes('quarter') || q.includes('90 day')) timeframe = '90d';
if (q.includes('week') || q.includes('7 day')) timeframe = '7d';
let intent: IntentType = 'general';
if (
q.includes('staff') ||
q.includes('team') ||
q.includes('attendance') ||
q.includes('roster') ||
q.includes('employee') ||
q.includes('sneha')
) {
intent = 'staff';
} else if (
q.includes('reward') ||
q.includes('lyt') ||
q.includes('redeem') ||
q.includes('points') ||
q.includes('campaign')
) {
intent = 'rewards';
} else if (
q.includes('compare') ||
q.includes('store') ||
q.includes('outlet') ||
q.includes('location') ||
storesFound.length > 0
) {
intent = 'stores';
} else if (
q.includes('inventory') ||
q.includes('stock') ||
q.includes('product') ||
q.includes('reorder') ||
q.includes('item')
) {
intent = 'inventory';
} else if (
q.includes('customer') ||
q.includes('retention') ||
q.includes('churn') ||
q.includes('repeat') ||
q.includes('cohort')
) {
intent = 'retention';
} else if (
q.includes('billing') ||
q.includes('invoice') ||
q.includes('subscription') ||
q.includes('payment') ||
q.includes('plan')
) {
intent = 'billing';
} else if (
q.includes('security') ||
q.includes('auth') ||
q.includes('login') ||
q.includes('password') ||
q.includes('2fa')
) {
intent = 'security';
} else if (
q.includes('sales') ||
q.includes('revenue') ||
q.includes('today') ||
q.includes('report') ||
q.includes('summary') ||
q.includes('earnings')
) {
intent = 'sales';
} else if (
q.includes('analytics') ||
q.includes('trend') ||
q.includes('growth') ||
q.includes('performance')
) {
intent = 'analytics';
}
return {
intent,
entities: {
stores: storesFound,
metrics: metricsFound,
timeframe,
query: prompt,
},
};
}

View File

@@ -0,0 +1,34 @@
import {RouteResult} from './promptRouter';
import {buildSalesTemplate} from './templates/sales';
import {buildStoresTemplate} from './templates/stores';
import {buildRewardsTemplate} from './templates/rewards';
import {buildStaffTemplate} from './templates/staff';
import {buildInventoryTemplate} from './templates/inventory';
import {buildAnalyticsTemplate} from './templates/analytics';
import {buildGeneralTemplate} from './templates/general';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function generateReportResponse(route: RouteResult): ReportPart {
const {intent, entities} = route;
switch (intent) {
case 'sales':
return buildSalesTemplate(entities);
case 'stores':
return buildStoresTemplate(entities);
case 'rewards':
return buildRewardsTemplate(entities);
case 'staff':
return buildStaffTemplate(entities);
case 'inventory':
return buildInventoryTemplate(entities);
case 'retention':
case 'analytics':
return buildAnalyticsTemplate(entities);
case 'billing':
case 'security':
case 'general':
default:
return buildGeneralTemplate(entities);
}
}

View File

@@ -0,0 +1,29 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildAnalyticsTemplate(entities: ExtractedEntities): ReportPart {
return {
type: 'report',
title: `Retail Analytics & Customer Retention Report`,
summary: `90-day retention and cohort analysis for **"${entities.query}"**. Repeat visit rate sits at **38%** with a median visit cycle of 17 days. 1,240 previously active buyers are flagged as at-risk.`,
kpis: [
{id: 'ak1', title: '90-Day Repeat Rate', value: '38.0%', trend: '+1.8%', trendDirection: 'up', iconName: 'conversion'},
{id: 'ak2', title: 'Median Visit Gap', value: '17 Days', trend: '-2 Days', trendDirection: 'up', iconName: 'sessions'},
{id: 'ak3', title: 'At-Risk Cohort', value: '1,240', trend: '₹2.1L Spend', iconName: 'alert'},
],
insights: [
{
id: 'ai1',
type: 'opportunity',
title: 'At-Risk Customer Recovery Campaign',
explanation: '1,240 customers who spent ₹2.1L have not visited in 45+ days. A targeted 100-LYT bonus will recover an estimated ₹85,000 in revenue.',
confidence: 95,
recommendedAction: 'Launch Automated Winback Campaign in LYT Rewards Manager.',
},
],
actions: [
{id: 'aa1', label: 'View Rewards Manager', actionType: 'view_rewards', iconName: 'lyts'},
{id: 'aa2', label: 'Export Cohort CSV', actionType: 'export_csv', iconName: 'download'},
],
};
}

View File

@@ -0,0 +1,31 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildGeneralTemplate(entities: ExtractedEntities): ReportPart {
const query = entities.query;
return {
type: 'report',
title: `AI Business Assistance: ${query.length > 30 ? query.slice(0, 30) + '...' : query}`,
summary: `I analyzed your inquiry: **"${query}"** against active network store data. Here is an overview of relevant metrics and recommended operational next steps.`,
kpis: [
{id: 'gk1', title: 'Network Stores', value: '4 Active', iconName: 'stores'},
{id: 'gk2', title: 'Monthly Revenue', value: '₹14.99L', trend: '+4.8%', iconName: 'revenue'},
{id: 'gk3', title: 'Active Customers', value: '14,250', iconName: 'visitors'},
],
insights: [
{
id: 'gi1',
type: 'growth',
title: 'Contextual AI Assistance',
explanation: `Based on your request regarding "${query}", you can inspect performance directly across your dashboard or run targeted store comparisons.`,
confidence: 90,
recommendedAction: 'Explore store analytics or loyalty reward settings.',
},
],
actions: [
{id: 'ga1', label: 'Open Main Dashboard', actionType: 'open_dashboard', iconName: 'dashboard'},
{id: 'ga2', label: 'Compare Stores', actionType: 'compare_stores', iconName: 'stores'},
],
};
}

View File

@@ -0,0 +1,46 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildInventoryTemplate(entities: ExtractedEntities): ReportPart {
return {
type: 'report',
title: `Inventory & Stock Reorder Health Report`,
summary: `Stock analytics for **"${entities.query}"**. 3 fast-moving SKUs are approaching low stock thresholds at **Indiranagar Flagship** and **Koramangala**.`,
kpis: [
{id: 'ik1', title: 'Low Stock SKUs', value: '3 Items', trend: 'Reorder needed', iconName: 'alert'},
{id: 'ik2', title: 'Stock Value', value: '₹14.2L', trend: 'Across 4 stores', iconName: 'revenue'},
{id: 'ik3', title: 'Turnover Velocity', value: '14 Days', trend: 'Fast-moving', iconName: 'purchases'},
],
tables: [
{
title: 'Inventory Stock Alerts & Reorder Points',
columns: [
{key: 'item', header: 'Item Description'},
{key: 'store', header: 'Store'},
{key: 'stock', header: 'In Stock', align: 'end'},
{key: 'reorder', header: 'Reorder Point', align: 'end'},
{key: 'status', header: 'Status', align: 'center'},
],
rows: [
{item: 'Loyaly Premium Gift Cards', store: 'Indiranagar Flagship', stock: '14 units', reorder: '50 units', status: 'warning'},
{item: 'VIP Member Lanyards', store: 'Koramangala 80ft', stock: '8 units', reorder: '30 units', status: 'warning'},
{item: 'POS Thermal Roll Packs', store: 'Jayanagar 4th Block', stock: '42 units', reorder: '20 units', status: 'active'},
],
},
],
insights: [
{
id: 'ii1',
type: 'opportunity',
title: 'Automated Stock Reorder Recommendation',
explanation: 'Loyaly Premium Gift Cards are selling 3x faster than average daily forecast.',
confidence: 95,
recommendedAction: 'Trigger automatic PO for 100 units from central warehouse.',
},
],
actions: [
{id: 'ia1', label: 'Open Integrations Settings', actionType: 'open_dashboard', iconName: 'integrations'},
{id: 'ia2', label: 'Export Inventory CSV', actionType: 'export_csv', iconName: 'download'},
],
};
}

View File

@@ -0,0 +1,45 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildRewardsTemplate(entities: ExtractedEntities): ReportPart {
return {
type: 'report',
title: `Rewards & LYTs Performance Report`,
summary: `Analysis of active loyalty campaigns for **"${entities.query}"**. Total claims reached **1,442** this month with a **54.2%** redemption rate. Outstanding LYT liability is currently **1.2L LYTs**.`,
kpis: [
{id: 'rk1', title: 'Total Claims', value: '1,442', trend: '+14%', trendDirection: 'up', iconName: 'lyt'},
{id: 'rk2', title: 'Redemption Rate', value: '54.2%', trend: '+3.1%', trendDirection: 'up', iconName: 'conversion'},
{id: 'rk3', title: 'Outstanding LYTs', value: '1.2L LYTs', trend: '₹1.2L value', iconName: 'lyt'},
],
tables: [
{
title: 'Active Reward Campaigns Breakdown',
columns: [
{key: 'campaign', header: 'Campaign Name'},
{key: 'claims', header: 'Total Claims', align: 'end'},
{key: 'redemption', header: 'Redemption Rate', align: 'end'},
{key: 'status', header: 'Status', align: 'center'},
],
rows: [
{campaign: 'Buy 1 Get 1 Free', claims: '688', redemption: '19.0%', status: 'warning'},
{campaign: 'Combo 20% Off', claims: '420', redemption: '61.0%', status: 'active'},
{campaign: 'Weekend Double LYTs', claims: '334', redemption: '74.0%', status: 'active'},
],
},
],
insights: [
{
id: 'ri1',
type: 'opportunity',
title: 'Shorten BOGO Redemption Expiry',
explanation: '81% of BOGO claims remain unredeemed after 14 days, creating unfulfilled customer expectations and pending liability.',
confidence: 94,
recommendedAction: 'Reduce expiry window from 14 days to 5 days to trigger immediate store visits.',
},
],
actions: [
{id: 'ra1', label: 'Manage Rewards & LYTs', actionType: 'view_rewards', iconName: 'lyts'},
{id: 'ra2', label: 'Export Redemption Log CSV', actionType: 'export_csv', iconName: 'download'},
],
};
}

View File

@@ -0,0 +1,92 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildSalesTemplate(entities: ExtractedEntities): ReportPart {
const storeContext = entities.stores.length > 0 ? entities.stores.join(', ') : 'All Active Outlets';
return {
type: 'report',
title: `Sales & Revenue Performance — ${storeContext}`,
summary: `Analysis generated for **"${entities.query}"**. Total revenue across ${storeContext} reached **₹3,40,000** today (+2.8% vs yesterday). Conversion rate stands at **21.5%** across 1,284 store visits.`,
kpis: [
{
id: 'k1',
title: 'Total Revenue',
value: '₹3,40,000',
trend: '+2.8%',
trendDirection: 'up',
iconName: 'revenue',
sparkline: [2.8, 3.1, 2.9, 3.2, 3.4],
},
{
id: 'k2',
title: 'Visitor Footfall',
value: '1,284',
trend: '+4.2%',
trendDirection: 'up',
iconName: 'visitors',
sparkline: [1150, 1200, 1180, 1240, 1284],
},
{
id: 'k3',
title: 'Completed Purchases',
value: '276',
trend: '-1.1%',
trendDirection: 'down',
iconName: 'purchases',
sparkline: [290, 285, 280, 278, 276],
},
{
id: 'k4',
title: 'LYTs Rewarded',
value: '34,100',
trend: '+6.0%',
trendDirection: 'up',
iconName: 'lyt',
sparkline: [28000, 30000, 31500, 32000, 34100],
},
],
charts: [
{
title: 'Hourly Revenue & Footfall Breakdown',
subtitle: 'Real-time telemetry updated every 15 minutes',
chartType: 'area',
xKey: 'time',
data: [
{time: '10:00', Revenue: 25000, Footfall: 120},
{time: '12:00', Revenue: 62000, Footfall: 240},
{time: '14:00', Revenue: 85000, Footfall: 310},
{time: '16:00', Revenue: 78000, Footfall: 280},
{time: '18:00', Revenue: 90000, Footfall: 334},
],
series: [
{key: 'Revenue', label: 'Revenue (₹)'},
{key: 'Footfall', label: 'Visitors'},
],
},
],
insights: [
{
id: 'si1',
type: 'growth',
title: 'Peak Evening Surge Detected',
explanation: 'Indiranagar Flagship experienced a 38% revenue spike between 17:30 and 19:30 IST.',
confidence: 96,
recommendedAction: 'Ensure 2 extra staff members are assigned to express checkout lanes after 17:00.',
},
{
id: 'si2',
type: 'opportunity',
title: 'Basket Size Upsell Opportunity',
explanation: 'Average order value in Koramangala is ₹1,230 vs ₹1,680 in Indiranagar.',
confidence: 91,
recommendedAction: 'Trigger a +50 LYT bonus for orders exceeding ₹1,500.',
},
],
actions: [
{id: 'sa1', label: 'Open Main Dashboard', actionType: 'open_dashboard', iconName: 'dashboard'},
{id: 'sa2', label: 'Compare Outlet Trends', actionType: 'compare_stores', iconName: 'stores'},
{id: 'sa3', label: 'Export Sales CSV', actionType: 'export_csv', iconName: 'download'},
],
};
}

View File

@@ -0,0 +1,46 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildStaffTemplate(entities: ExtractedEntities): ReportPart {
return {
type: 'report',
title: `Staff Roster & Productivity Report`,
summary: `Roster analysis for **"${entities.query}"**. **35 of 49** floor staff are currently checked in across all outlets. **Sneha R** leads network productivity with 312 transactions.`,
kpis: [
{id: 'stk1', title: 'Active Floor Staff', value: '35 / 49', trend: '14 Absent', iconName: 'staff'},
{id: 'stk2', title: 'Network Avg Score', value: '88.4', trend: '+2.1', trendDirection: 'up', iconName: 'leaderboard'},
{id: 'stk3', title: 'Shift Transactions', value: '1,240', iconName: 'purchases'},
],
tables: [
{
title: 'Top Staff Performance Leaderboard',
columns: [
{key: 'name', header: 'Staff Member'},
{key: 'store', header: 'Assigned Outlet'},
{key: 'txns', header: 'Transactions', align: 'end'},
{key: 'score', header: 'Score', align: 'end'},
{key: 'status', header: 'Shift Status', align: 'center'},
],
rows: [
{name: 'Sneha R', store: 'Indiranagar Flagship', txns: '312', score: '94', status: 'active'},
{name: 'Rohit K', store: 'Koramangala 80ft', txns: '287', score: '91', status: 'active'},
{name: 'Ravi M', store: 'Jayanagar 4th Block', txns: '264', score: '88', status: 'active'},
{name: 'Priya Sharma', store: 'Whitefield Main', txns: '198', score: '82', status: 'active'},
],
},
],
insights: [
{
id: 'sti1',
type: 'risk',
title: 'Whitefield Shift Roster Coverage Shortage',
explanation: 'Absence is concentrated at Whitefield Main (4 staff members missing during peak evening hours).',
confidence: 93,
recommendedAction: 'Reassign 2 floaters from Koramangala shift roster.',
},
],
actions: [
{id: 'sta1', label: 'Open Staff Roster Manager', actionType: 'open_dashboard', iconName: 'staff'},
],
};
}

View File

@@ -0,0 +1,57 @@
import type {ExtractedEntities} from '../promptRouter';
import type {ReportPart} from '@/features/loyaly-ai/types/chat';
export function buildStoresTemplate(entities: ExtractedEntities): ReportPart {
const isSpecificComparison = entities.stores.length >= 2;
const storeNames = isSpecificComparison ? entities.stores.join(' vs ') : 'Multi-Store Network';
return {
type: 'report',
title: `Store Comparison Analysis: ${storeNames}`,
summary: `Side-by-side performance evaluation for **${entities.query}**. Indiranagar Flagship continues to lead the network in conversion rate (23.8%), while Whitefield Main presents the highest potential for conversion recovery.`,
kpis: [
{id: 'stk1', title: 'Top Store Revenue', value: '₹9.1L', trend: 'Indiranagar Flagship', iconName: 'stores'},
{id: 'stk2', title: 'Avg Network Conversion', value: '21.1%', trend: '+1.4% vs last mo', iconName: 'conversion'},
{id: 'stk3', title: 'Active Outlets', value: '4 Tracked', iconName: 'stores'},
],
tables: [
{
title: 'Network Outlets Comparison Breakdown',
columns: [
{key: 'store', header: 'Store Location'},
{key: 'visitors', header: 'Visitors', align: 'end'},
{key: 'revenue', header: 'Revenue', align: 'end'},
{key: 'conversion', header: 'Conversion', align: 'end'},
{key: 'status', header: 'Status', align: 'center'},
],
rows: [
{store: 'Indiranagar Flagship', visitors: '12,400', revenue: '₹9,10,000', conversion: '23.8%', status: 'active'},
{store: 'Koramangala 80ft', visitors: '7,800', revenue: '₹5,20,000', conversion: '21.4%', status: 'active'},
{store: 'Jayanagar 4th Block', visitors: '6,100', revenue: '₹4,00,000', conversion: '20.9%', status: 'active'},
{store: 'Whitefield Main', visitors: '4,200', revenue: '₹3,30,000', conversion: '18.2%', status: 'warning'},
],
},
],
insights: [
{
id: 'sti1',
type: 'best_performer',
title: 'Indiranagar Benchmark Conversion',
explanation: 'Indiranagar conversion rate exceeds network average by +2.7 percentage points.',
confidence: 98,
},
{
id: 'sti2',
type: 'risk',
title: 'Whitefield Conversion Underperformance',
explanation: 'Whitefield is 5.6 points behind flagship despite high visitor traffic per staff seat.',
confidence: 89,
recommendedAction: 'Conduct staff training on checkout upsell workflows.',
},
],
actions: [
{id: 'sta1', label: 'View Stores Directory', actionType: 'compare_stores', iconName: 'stores'},
{id: 'sta2', label: 'Download Store Data CSV', actionType: 'export_csv', iconName: 'download'},
],
};
}

View File

@@ -0,0 +1,115 @@
import type {
ChatMessage,
Conversation,
ConversationSummary,
MessagePart,
MessageRole,
} from '@/features/loyaly-ai/types/chat';
/**
* Domain rules for Loyaly AI.
*
* Framework-free: no React, no Astryx. Everything here is about what a
* conversation IS — how a message is built, how a chat gets its title, what
* order history appears in — and none of it should have to change when the
* surface is redesigned or the transport is swapped.
*/
/**
* Ids are generated, and deliberately not with Math.random() or Date.now()
* during render. A monotonic counter seeded per module gives stable,
* collision-free ids without touching either.
*/
let sequence = 0;
function nextId(prefix: string): string {
sequence += 1;
return `${prefix}_${sequence}`;
}
export function createMessage(
role: MessageRole,
parts: MessagePart[],
options?: {isStreaming?: boolean},
): ChatMessage {
return {
id: nextId(role === 'user' ? 'usr' : 'ast'),
role,
parts,
// Stamped on a user event, never during render — Date.now() in a render
// path is a hydration mismatch waiting to happen.
at: new Date().toISOString(),
isStreaming: options?.isStreaming,
};
}
export function createConversation(): Conversation {
const now = new Date().toISOString();
return {
id: nextId('conv'),
title: 'New chat',
messages: [],
createdAt: now,
updatedAt: now,
};
}
/** The longest prefix of a title that reads as a phrase rather than a truncation. */
const TITLE_MAX = 48;
/**
* Name a conversation from its first user message.
*
* Cut on a word boundary rather than mid-word: "Compare stores by conve…" is
* a title, "Compare stores by conv" is a bug. Falls back to the raw slice only
* when the first word is itself longer than the budget.
*/
export function titleFromPrompt(prompt: string): string {
const clean = prompt.trim().replace(/\s+/g, ' ');
if (!clean) return 'New chat';
if (clean.length <= TITLE_MAX) return clean;
const cut = clean.slice(0, TITLE_MAX);
const lastSpace = cut.lastIndexOf(' ');
return `${lastSpace > 16 ? cut.slice(0, lastSpace) : cut}`;
}
/**
* History, most recently touched first, with empty conversations omitted.
*
* A chat you opened and never used is not history — listing it means "New
* chat" accumulates in the drawer every time someone clicks the button.
*/
export function toHistory(conversations: Conversation[]): ConversationSummary[] {
return conversations
.filter((c) => c.messages.length > 0)
.slice()
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map((c) => ({
id: c.id,
title: c.title,
updatedAt: c.updatedAt,
messageCount: c.messages.length,
}));
}
/**
* Apply a streamed part to the assistant message being written.
*
* Text REPLACES the last text part (the repository yields cumulative text, so
* a dropped chunk cannot corrupt the message); anything else appends, because
* a chart and a table are separate blocks rather than revisions of one.
*/
export function applyStreamedPart(
message: ChatMessage,
part: MessagePart,
): ChatMessage {
const parts = [...message.parts];
const lastIndex = parts.length - 1;
if (lastIndex >= 0 && parts[lastIndex].type === part.type) {
parts[lastIndex] = part;
} else {
parts.push(part);
}
return {...message, parts};
}

View File

@@ -0,0 +1,157 @@
/**
* The Loyaly AI conversation model.
*
* ── Why a message is PARTS, not a string ──────────────────────────────────
* An assistant that answers business questions will not answer only in prose.
* "Compare stores" wants a table, "analyze sales" wants a chart, an uploaded
* invoice wants a file preview. Modelling a message as `text: string` forces
* every one of those to be smuggled through markdown or bolted on later as a
* second field, and both roads end in a renderer that switches on the shape of
* a string.
*
* So a message carries an ordered list of PARTS, each independently typed and
* independently rendered (see components/MessageContent). Today only `text`
* has a renderer. Adding charts is a new variant here plus a case there —
* nothing about the transport, the provider or the message list changes.
*/
export type MessageRole = 'user' | 'assistant';
/** Prose. Rendered as markdown: lists, tables, code blocks all come free. */
export interface TextPart {
type: 'text';
text: string;
}
/**
* A chart the assistant chose to draw. Deliberately references a dataset by id
* rather than embedding points: an answer that ships 90 days of raw series
* inside a chat message cannot be cached, diffed or re-rendered at a new size.
*/
export interface ChartPart {
type: 'chart';
chart: 'line' | 'bar' | 'area';
title: string;
datasetId: string;
}
/** A result set the merchant can sort and page through. */
export interface TablePart {
type: 'table';
title: string;
columns: {key: string; header: string}[];
rows: Record<string, string | number>[];
}
/** An uploaded or generated artefact. */
export interface FilePart {
type: 'file';
name: string;
mimeType: string;
sizeBytes: number;
url: string;
}
export interface ImagePart {
type: 'image';
url: string;
alt: string;
}
export interface ReportKpi {
id: string;
title: string;
value: string;
trend?: string;
trendDirection?: 'up' | 'down' | 'neutral';
iconName?: string;
sparkline?: number[];
}
export interface ReportInsight {
id: string;
type: 'opportunity' | 'risk' | 'growth' | 'best_performer';
title: string;
explanation: string;
confidence?: number;
recommendedAction?: string;
}
export interface ReportAction {
id: string;
label: string;
iconName?: string;
actionType: string;
}
export interface ChartPartSpec {
title: string;
subtitle?: string;
chartType: 'line' | 'bar' | 'area';
data: Record<string, any>[];
xKey: string;
series: {key: string; label: string}[];
}
export interface TablePartSpec {
title: string;
columns: {key: string; header: string; align?: 'start' | 'center' | 'end'}[];
rows: Record<string, any>[];
}
export interface ReportPart {
type: 'report';
title?: string;
summary?: string;
kpis?: ReportKpi[];
charts?: ChartPartSpec[];
tables?: TablePartSpec[];
insights?: ReportInsight[];
actions?: ReportAction[];
}
export type MessagePart =
| TextPart
| ChartPart
| TablePart
| FilePart
| ImagePart
| ReportPart;
export interface ChatMessage {
id: string;
role: MessageRole;
parts: MessagePart[];
/** ISO-8601, stamped on send — never during render. */
at: string;
/**
* True while tokens are still arriving. Drives the streaming cursor and
* tells Markdown to tolerate half-finished syntax.
*/
isStreaming?: boolean;
}
export interface Conversation {
id: string;
/** Derived from the first user message; "New chat" until there is one. */
title: string;
messages: ChatMessage[];
createdAt: string;
updatedAt: string;
}
/** A conversation as the history drawer needs it — no message bodies. */
export interface ConversationSummary {
id: string;
title: string;
updatedAt: string;
messageCount: number;
}
/** One suggestion chip on the empty state. */
export interface Suggestion {
id: string;
label: string;
/** What is actually sent — chips are short, prompts should not be. */
prompt: string;
}

View File

@@ -0,0 +1,51 @@
import type {Suggestion} from '@/features/loyaly-ai/types/chat';
/**
* The empty state's opening moves.
*
* Chips, not buttons in a grid: the label is short enough to scan a row of
* them, and what gets SENT is the fuller question underneath. That split
* matters — "Compare stores" is a good chip and a poor prompt, and a merchant
* who sends it should get the same answer as one who typed the long form.
*
* Ordered by how often a merchant opens the workspace wanting it. They vanish
* after the first message; a suggestion rail that persists through a
* conversation is a toolbar, and this is not a toolbar.
*/
export const SUGGESTIONS: Suggestion[] = [
{
id: 'today',
label: "Today's report",
prompt: "Give me today's report across all stores.",
},
{
id: 'compare',
label: 'Compare stores',
prompt: 'Compare my stores on revenue, footfall and conversion.',
},
{
id: 'sales',
label: 'Analyze sales',
prompt: 'Analyze the sales trend over the last 30 days and tell me what changed.',
},
{
id: 'rewards',
label: 'Reward insights',
prompt: 'Which rewards are underperforming, and what should I change?',
},
{
id: 'staff',
label: 'Staff performance',
prompt: 'How is my staff performing, and where is attendance a problem?',
},
{
id: 'inventory',
label: 'Inventory health',
prompt: 'How is my inventory looking — anything running low or sitting dead?',
},
{
id: 'retention',
label: 'Customer retention',
prompt: 'How is customer retention trending, and which customers are at risk?',
},
];