update ui update and fix layout issue
This commit is contained in:
100
src/features/loyaly-ai/components/ChatHeader.tsx
Normal file
100
src/features/loyaly-ai/components/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/features/loyaly-ai/components/Composer.tsx
Normal file
139
src/features/loyaly-ai/components/Composer.tsx
Normal 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 56–64px 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>
|
||||
);
|
||||
}
|
||||
126
src/features/loyaly-ai/components/Conversation.tsx
Normal file
126
src/features/loyaly-ai/components/Conversation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/features/loyaly-ai/components/EmptyState.tsx
Normal file
70
src/features/loyaly-ai/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/features/loyaly-ai/components/HistoryDrawer.tsx
Normal file
80
src/features/loyaly-ai/components/HistoryDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/features/loyaly-ai/components/LoyalyAiPanel.tsx
Normal file
30
src/features/loyaly-ai/components/LoyalyAiPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/features/loyaly-ai/components/LoyalyAiSlideOver.tsx
Normal file
71
src/features/loyaly-ai/components/LoyalyAiSlideOver.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/features/loyaly-ai/components/LoyalyAiToggle.tsx
Normal file
31
src/features/loyaly-ai/components/LoyalyAiToggle.tsx
Normal 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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
src/features/loyaly-ai/components/MessageBubble.tsx
Normal file
66
src/features/loyaly-ai/components/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/features/loyaly-ai/components/MessageContent.tsx
Normal file
64
src/features/loyaly-ai/components/MessageContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/features/loyaly-ai/components/MessageList.tsx
Normal file
44
src/features/loyaly-ai/components/MessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/features/loyaly-ai/components/ResizeHandle.tsx
Normal file
60
src/features/loyaly-ai/components/ResizeHandle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/features/loyaly-ai/components/SuggestionChips.tsx
Normal file
42
src/features/loyaly-ai/components/SuggestionChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/features/loyaly-ai/components/TypingIndicator.tsx
Normal file
33
src/features/loyaly-ai/components/TypingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
53
src/features/loyaly-ai/components/response/ChartBlock.tsx
Normal file
53
src/features/loyaly-ai/components/response/ChartBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
85
src/features/loyaly-ai/components/response/KpiGridBlock.tsx
Normal file
85
src/features/loyaly-ai/components/response/KpiGridBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
src/features/loyaly-ai/components/response/SummaryCard.tsx
Normal file
29
src/features/loyaly-ai/components/response/SummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/features/loyaly-ai/components/response/TableBlock.tsx
Normal file
90
src/features/loyaly-ai/components/response/TableBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
319
src/features/loyaly-ai/mock/replies.mock.ts
Normal file
319
src/features/loyaly-ai/mock/replies.mock.ts
Normal 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;
|
||||
}
|
||||
340
src/features/loyaly-ai/providers/LoyalyAiProvider.tsx
Normal file
340
src/features/loyaly-ai/providers/LoyalyAiProvider.tsx
Normal 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};
|
||||
24
src/features/loyaly-ai/repositories/loyalyAiRepository.ts
Normal file
24
src/features/loyaly-ai/repositories/loyalyAiRepository.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
83
src/features/loyaly-ai/services/ai/mockAi.ts
Normal file
83
src/features/loyaly-ai/services/ai/mockAi.ts
Normal 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;
|
||||
}
|
||||
131
src/features/loyaly-ai/services/ai/promptRouter.ts
Normal file
131
src/features/loyaly-ai/services/ai/promptRouter.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/features/loyaly-ai/services/ai/responseGenerator.ts
Normal file
34
src/features/loyaly-ai/services/ai/responseGenerator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/features/loyaly-ai/services/ai/templates/analytics.ts
Normal file
29
src/features/loyaly-ai/services/ai/templates/analytics.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
31
src/features/loyaly-ai/services/ai/templates/general.ts
Normal file
31
src/features/loyaly-ai/services/ai/templates/general.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
46
src/features/loyaly-ai/services/ai/templates/inventory.ts
Normal file
46
src/features/loyaly-ai/services/ai/templates/inventory.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
45
src/features/loyaly-ai/services/ai/templates/rewards.ts
Normal file
45
src/features/loyaly-ai/services/ai/templates/rewards.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
92
src/features/loyaly-ai/services/ai/templates/sales.ts
Normal file
92
src/features/loyaly-ai/services/ai/templates/sales.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
46
src/features/loyaly-ai/services/ai/templates/staff.ts
Normal file
46
src/features/loyaly-ai/services/ai/templates/staff.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
57
src/features/loyaly-ai/services/ai/templates/stores.ts
Normal file
57
src/features/loyaly-ai/services/ai/templates/stores.ts
Normal 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'},
|
||||
],
|
||||
};
|
||||
}
|
||||
115
src/features/loyaly-ai/services/loyalyAiService.ts
Normal file
115
src/features/loyaly-ai/services/loyalyAiService.ts
Normal 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};
|
||||
}
|
||||
157
src/features/loyaly-ai/types/chat.ts
Normal file
157
src/features/loyaly-ai/types/chat.ts
Normal 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;
|
||||
}
|
||||
51
src/features/loyaly-ai/utils/suggestions.ts
Normal file
51
src/features/loyaly-ai/utils/suggestions.ts
Normal 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?',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user