Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,84 @@ 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 <LandingPage onSelectUserType={setUserType} />
return (
<LandingPage
conversations={conversations}
onClearHistory={handleClearHistory}
onOpenConversation={handleOpenConversation}
onSelectUserType={handleSelectUserType}
/>
)
}

if (userType === 'buyer') {
return (
<BuyerPage
onBack={() => setUserType(null)}
conversation={activeConversation}
onBack={handleBack}
onConversationChange={handleConversationChange}
onCopy={showToast}
/>
)
}

return (
<SellerPage
conversation={activeConversation}
toast={toast}
onBack={() => setUserType(null)}
onBack={handleBack}
onConversationChange={handleConversationChange}
onCopy={showToast}
/>
)
Expand Down
16 changes: 8 additions & 8 deletions src/features/chat/ChatMessage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<article className={`flex ${isAssistant ? 'justify-start' : 'justify-end'}`}>
<article className={`flex ${alignmentClass}`}>
<div
className={[
'max-w-[88%] rounded-lg px-4 py-3 shadow-sm',
isAssistant
? 'border border-stone-200 bg-white text-stone-800 shadow-stone-900/5'
: 'bg-primary-700 text-white shadow-primary-900/15',
].join(' ')}
className={`max-w-[88%] rounded-lg px-4 py-3 shadow-sm ${bubbleClass}`}
>
<div className="mb-2 flex items-center justify-between gap-3">
<span className={`text-xs font-semibold uppercase tracking-[0.14em] ${isAssistant ? 'text-primary-700' : 'text-primary-100'}`}>
<span className={`text-xs font-semibold uppercase tracking-[0.14em] ${labelClass}`}>
{isAssistant ? 'Haggly' : 'You'}
</span>
{isAssistant && <CopyButton text={message.content} onCopy={onCopy} />}
Expand Down
62 changes: 53 additions & 9 deletions src/features/chat/ChatPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`,
Expand All @@ -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 (
<Surface className="overflow-hidden">
<div className="border-b border-stone-200 px-4 py-3">
<p className="text-sm font-semibold text-stone-950">Negotiation chat</p>
<p className="mt-1 text-xs text-stone-500">Mock Haggly agent for now. No API key needed.</p>
<div className="flex items-start justify-between gap-4 border-b border-stone-200 px-4 py-3">
<div>
<p className="text-sm font-semibold text-stone-950">Negotiation chat</p>
<p className="mt-1 text-xs text-stone-500">Mock Haggly agent for now. No API key needed.</p>
</div>
<label className="text-xs font-semibold text-stone-500">
Status
<select
value={conversationStatus}
onChange={handleStatusChange}
className="ml-2 rounded-md border border-stone-200 bg-white px-2 py-1 text-xs font-semibold text-stone-700 focus:border-primary-500 focus:outline-none"
>
<option value="active">Active</option>
<option value="accepted">Accepted</option>
<option value="declined">Declined</option>
<option value="expired">Expired</option>
</select>
</label>
</div>

<div className="max-h-[520px] min-h-[360px] space-y-4 overflow-y-auto bg-stone-50/70 p-4">
Expand Down
76 changes: 76 additions & 0 deletions src/features/dashboard/ConversationDashboard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Surface className="p-5 sm:p-6">
<div className="mb-5 flex items-start justify-between gap-4 border-b border-stone-200 pb-4">
<div>
<p className="text-sm font-semibold text-stone-950">Local dashboard</p>
<p className="mt-1 text-sm text-stone-500">Saved in this browser only. No login or cloud sync.</p>
</div>
{conversations.length > 0 && (
<Button onClick={onClearHistory} variant="ghost" size="sm">
<Trash2 className="h-4 w-4" aria-hidden="true" />
Clear
</Button>
)}
</div>

{conversations.length === 0 ? (
<div className="rounded-lg border border-dashed border-stone-300 bg-stone-50/70 p-5 text-sm leading-6 text-stone-500">
Start a buyer or seller chat. Your local sessions will appear here.
</div>
) : (
<div className="space-y-3">
{conversations.slice(0, 5).map((conversation) => (
<button
key={conversation.id}
onClick={() => onOpenConversation(conversation)}
className="w-full rounded-lg border border-stone-200 bg-white p-4 text-left shadow-sm shadow-stone-900/5 transition hover:border-primary-200 hover:bg-primary-50/40"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold capitalize text-stone-950">
{conversation.mode} negotiation
</span>
<span className="rounded-full bg-primary-100 px-2.5 py-1 text-xs font-semibold text-primary-800">
{statusLabels[conversation.status] ?? statusLabels.active}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-stone-600">
{conversation.summary}
</p>
<p className="mt-3 flex items-center gap-1.5 text-xs font-medium text-stone-400">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{formatUpdatedAt(conversation.updatedAt)}
</p>
</button>
))}
</div>
)}
</Surface>
)
}

export default ConversationDashboard
63 changes: 63 additions & 0 deletions src/lib/storage/conversationStorage.js
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 7 additions & 2 deletions src/pages/BuyerPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -45,7 +45,12 @@ function BuyerPage({ onBack, onCopy }) {
</div>
</section>

<ChatPanel mode="buyer" onCopy={onCopy} />
<ChatPanel
conversation={conversation}
mode="buyer"
onConversationChange={onConversationChange}
onCopy={onCopy}
/>
</main>
</div>
)
Expand Down
Loading