From 1cd90d173a839d4a3881efe6b68e43fd6bae07da Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 28 Apr 2026 13:40:47 -0700 Subject: [PATCH] Add local conversation dashboard --- src/App.jsx | 57 +++++++++++++- src/features/chat/ChatMessage.jsx | 16 ++-- src/features/chat/ChatPanel.jsx | 62 ++++++++++++--- .../dashboard/ConversationDashboard.jsx | 76 +++++++++++++++++++ src/lib/storage/conversationStorage.js | 63 +++++++++++++++ src/pages/BuyerPage.jsx | 9 ++- src/pages/LandingPage.jsx | 63 ++++++++------- src/pages/SellerPage.jsx | 9 ++- tests/e2e/chat.spec.js | 19 +++++ 9 files changed, 323 insertions(+), 51 deletions(-) create mode 100644 src/features/dashboard/ConversationDashboard.jsx create mode 100644 src/lib/storage/conversationStorage.js diff --git a/src/App.jsx b/src/App.jsx index b768183..3da2ea6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,24 +2,73 @@ import { useState } from 'react' import LandingPage from './pages/LandingPage' import BuyerPage from './pages/BuyerPage' import SellerPage from './pages/SellerPage' +import { + clearStoredConversations, + getStoredConversations, + saveConversation, +} from './lib/storage/conversationStorage' function App() { const [userType, setUserType] = useState(null) // 'buyer' or 'seller' + const [activeConversation, setActiveConversation] = useState(null) + const [conversations, setConversations] = useState(() => getStoredConversations()) const [toast, setToast] = useState(null) + const refreshConversations = () => { + setConversations(getStoredConversations()) + } + const showToast = (message) => { setToast(message) setTimeout(() => setToast(null), 2000) } + const handleSelectUserType = (mode) => { + setActiveConversation(null) + setUserType(mode) + } + + const handleOpenConversation = (conversation) => { + setActiveConversation(conversation) + setUserType(conversation.mode) + } + + const handleConversationChange = (conversation) => { + const savedConversation = saveConversation(conversation) + setActiveConversation(savedConversation) + setConversations(getStoredConversations()) + } + + const handleClearHistory = () => { + clearStoredConversations() + setActiveConversation(null) + setConversations([]) + showToast('Local history cleared') + } + + const handleBack = () => { + setActiveConversation(null) + refreshConversations() + setUserType(null) + } + if (!userType) { - return + return ( + + ) } if (userType === 'buyer') { return ( setUserType(null)} + conversation={activeConversation} + onBack={handleBack} + onConversationChange={handleConversationChange} onCopy={showToast} /> ) @@ -27,8 +76,10 @@ function App() { return ( setUserType(null)} + onBack={handleBack} + onConversationChange={handleConversationChange} onCopy={showToast} /> ) diff --git a/src/features/chat/ChatMessage.jsx b/src/features/chat/ChatMessage.jsx index ffa237c..a2302b5 100644 --- a/src/features/chat/ChatMessage.jsx +++ b/src/features/chat/ChatMessage.jsx @@ -2,19 +2,19 @@ import CopyButton from '../../components/CopyButton' function ChatMessage({ message, onCopy }) { const isAssistant = message.role === 'assistant' + const alignmentClass = isAssistant ? 'justify-start' : 'justify-end' + const bubbleClass = isAssistant + ? 'border border-stone-200 bg-white text-stone-800 shadow-stone-900/5' + : 'bg-primary-700 text-white shadow-primary-900/15' + const labelClass = isAssistant ? 'text-primary-700' : 'text-primary-100' return ( -
+
- + {isAssistant ? 'Haggly' : 'You'} {isAssistant && } diff --git a/src/features/chat/ChatPanel.jsx b/src/features/chat/ChatPanel.jsx index 02e9236..d146095 100644 --- a/src/features/chat/ChatPanel.jsx +++ b/src/features/chat/ChatPanel.jsx @@ -4,10 +4,34 @@ import { createInitialMessages, createMockAssistantReply } from './mockAssistant import ChatComposer from './ChatComposer' import ChatMessage from './ChatMessage' -function ChatPanel({ mode, onCopy }) { - const [messages, setMessages] = useState(() => createInitialMessages(mode)) +function ChatPanel({ conversation, mode, onConversationChange, onCopy }) { + const activeMode = conversation?.mode ?? mode + const [messages, setMessages] = useState(() => conversation?.messages ?? createInitialMessages(activeMode)) + const [conversationId, setConversationId] = useState(() => conversation?.id ?? `conversation-${Date.now()}`) + const [conversationStatus, setConversationStatus] = useState(() => conversation?.status ?? 'active') + const [conversationCreatedAt, setConversationCreatedAt] = useState(() => conversation?.createdAt ?? Date.now()) const [isReplying, setIsReplying] = useState(false) + const publishMessages = (nextMessages, status = conversationStatus) => { + const nextId = conversation?.id ?? conversationId + const nextCreatedAt = conversation?.createdAt ?? conversationCreatedAt + + setConversationId(nextId) + setConversationStatus(status) + setConversationCreatedAt(nextCreatedAt) + setMessages(nextMessages) + + onConversationChange?.({ + id: nextId, + mode: activeMode, + status, + summary: nextMessages.find((message) => message.role === 'user')?.content ?? `${activeMode} negotiation`, + messages: nextMessages, + createdAt: nextCreatedAt, + updatedAt: Date.now(), + }) + } + const handleSend = (content) => { const userMessage = { id: `user-${Date.now()}`, @@ -16,23 +40,43 @@ function ChatPanel({ mode, onCopy }) { createdAt: Date.now(), } - setMessages((currentMessages) => [...currentMessages, userMessage]) + const messagesWithUser = [...messages, userMessage] + publishMessages(messagesWithUser) setIsReplying(true) setTimeout(() => { - setMessages((currentMessages) => [ - ...currentMessages, - createMockAssistantReply({ mode, message: content }), + publishMessages([ + ...messagesWithUser, + createMockAssistantReply({ mode: activeMode, message: content }), ]) setIsReplying(false) }, 450) } + const handleStatusChange = (event) => { + publishMessages(messages, event.target.value) + } + return ( -
-

Negotiation chat

-

Mock Haggly agent for now. No API key needed.

+
+
+

Negotiation chat

+

Mock Haggly agent for now. No API key needed.

+
+
diff --git a/src/features/dashboard/ConversationDashboard.jsx b/src/features/dashboard/ConversationDashboard.jsx new file mode 100644 index 0000000..b59583a --- /dev/null +++ b/src/features/dashboard/ConversationDashboard.jsx @@ -0,0 +1,76 @@ +import { Clock, Trash2 } from 'lucide-react' +import Button from '../../components/ui/Button' +import Surface from '../../components/ui/Surface' + +const statusLabels = { + active: 'Active', + accepted: 'Accepted', + declined: 'Declined', + expired: 'Expired', +} + +function formatUpdatedAt(updatedAt) { + if (!updatedAt) { + return 'New' + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(updatedAt)) +} + +function ConversationDashboard({ conversations, onOpenConversation, onClearHistory }) { + return ( + +
+
+

Local dashboard

+

Saved in this browser only. No login or cloud sync.

+
+ {conversations.length > 0 && ( + + )} +
+ + {conversations.length === 0 ? ( +
+ Start a buyer or seller chat. Your local sessions will appear here. +
+ ) : ( +
+ {conversations.slice(0, 5).map((conversation) => ( + + ))} +
+ )} +
+ ) +} + +export default ConversationDashboard diff --git a/src/lib/storage/conversationStorage.js b/src/lib/storage/conversationStorage.js new file mode 100644 index 0000000..3f2c19a --- /dev/null +++ b/src/lib/storage/conversationStorage.js @@ -0,0 +1,63 @@ +const STORAGE_KEY = 'haggly:v2:conversations' +const ALLOWED_STATUSES = new Set(['active', 'accepted', 'declined', 'expired']) + +function isBrowserStorageAvailable() { + return typeof window !== 'undefined' && Boolean(window.localStorage) +} + +function parseConversations(rawValue) { + if (!rawValue) { + return [] + } + + try { + const parsed = JSON.parse(rawValue) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function readConversations() { + if (!isBrowserStorageAvailable()) { + return [] + } + + return parseConversations(window.localStorage.getItem(STORAGE_KEY)) +} + +function writeConversations(conversations) { + if (!isBrowserStorageAvailable()) { + return + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations)) +} + +export function getStoredConversations() { + return readConversations() + .filter((conversation) => conversation?.id && conversation?.mode && Array.isArray(conversation?.messages)) + .sort((a, b) => b.updatedAt - a.updatedAt) +} + +export function saveConversation(conversation) { + const existingConversations = readConversations() + const nextConversation = { + ...conversation, + status: ALLOWED_STATUSES.has(conversation.status) ? conversation.status : 'active', + updatedAt: Date.now(), + } + + const withoutCurrent = existingConversations.filter((item) => item.id !== nextConversation.id) + writeConversations([nextConversation, ...withoutCurrent]) + + return nextConversation +} + +export function clearStoredConversations() { + if (!isBrowserStorageAvailable()) { + return + } + + window.localStorage.removeItem(STORAGE_KEY) +} diff --git a/src/pages/BuyerPage.jsx b/src/pages/BuyerPage.jsx index 9c5a8a6..7e20f12 100644 --- a/src/pages/BuyerPage.jsx +++ b/src/pages/BuyerPage.jsx @@ -3,7 +3,7 @@ import BrandMark from '../components/BrandMark' import Button from '../components/ui/Button' import ChatPanel from '../features/chat/ChatPanel' -function BuyerPage({ onBack, onCopy }) { +function BuyerPage({ conversation, onBack, onConversationChange, onCopy }) { const upcomingTools = [ { icon: Search, label: 'Question builder' }, { icon: MessageSquareText, label: 'Response scripts' }, @@ -45,7 +45,12 @@ function BuyerPage({ onBack, onCopy }) {
- +
) diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 4bd01f9..36d1ce8 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -2,8 +2,9 @@ import { ArrowRight, ShoppingBag, Tag } from 'lucide-react' import BrandMark from '../components/BrandMark' import Button from '../components/ui/Button' import Surface from '../components/ui/Surface' +import ConversationDashboard from '../features/dashboard/ConversationDashboard' -function LandingPage({ onSelectUserType }) { +function LandingPage({ conversations, onClearHistory, onOpenConversation, onSelectUserType }) { return (
@@ -54,36 +55,44 @@ function LandingPage({ onSelectUserType }) {
- -
-
-

Workspace preview

-

Local, fast, and ready for the AI chat layer.

+
+ +
+
+

Workspace preview

+

Local, fast, and ready for the AI chat layer.

+
+ + no login +
- - no login - -
-
-
-

Current focus

-

Marketplace counter offer

-

- Generate a firm but fair response, keep the tone calm, and move toward pickup details. -

-
+
+
+

Current focus

+

Agentic negotiation read

+

+ Diagnose leverage, missing context, and the next move before drafting. +

+
-
- {['Context', 'Drafts', 'Outcome'].map((label) => ( -
-

{label}

-

-

- ))} +
+ {['Context', 'Strategy', 'Outcome'].map((label) => ( +
+

{label}

+

+

+ ))} +
-
- + + + +
diff --git a/src/pages/SellerPage.jsx b/src/pages/SellerPage.jsx index a29f848..55e7250 100644 --- a/src/pages/SellerPage.jsx +++ b/src/pages/SellerPage.jsx @@ -5,7 +5,7 @@ import Button from '../components/ui/Button' import Surface from '../components/ui/Surface' import ChatPanel from '../features/chat/ChatPanel' -function SellerPage({ toast, onBack, onCopy }) { +function SellerPage({ conversation, toast, onBack, onConversationChange, onCopy }) { return (
@@ -43,7 +43,12 @@ function SellerPage({ toast, onBack, onCopy }) { - +