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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ jobs:
- name: Test
run: npm test

- name: Install Playwright browser
run: npx playwright install chromium

- name: E2E
run: npm run test:e2e

- name: Build
run: npm run build
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ dist/
coverage/
AI_REVIEW_BRIEF.md
.claude/
test-results/
playwright-report/
22 changes: 16 additions & 6 deletions docs/HAGGLY_V2_AGENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ Last updated: 2026-04-28

Haggly v2 should launch as a no-login app first.

The MVP is a green-leaning, no-paywall AI negotiation chat app with a lightweight local dashboard. "Green" means visual direction and brand feel, not a sustainability claim.
The MVP is a green-leaning, no-paywall agentic AI negotiation workspace with a lightweight local dashboard. "Green" means visual direction and brand feel, not a sustainability claim.

Haggly v2 is its own product direction. It should not behave like the current `www.haggly.io` v1 static site or a simple script generator. The v2 assistant should act like a negotiation agent: diagnose leverage, identify missing context, ask useful follow-up questions, recommend the next move, and draft wording only when drafting is actually the next best step.

Do not build account login, billing, Stripe, or a hidden member dashboard for the first v2 launch. Those can come after the chat experience proves useful.

Expand All @@ -17,9 +19,10 @@ The first useful version should let a visitor:
1. Open Haggly without logging in.
2. Choose buyer or seller mode.
3. Describe the negotiation.
4. Chat with an AI negotiation assistant.
5. Copy suggested responses.
6. See recent negotiation sessions in a local dashboard.
4. Chat with a Haggly negotiation agent.
5. Get a read on leverage, missing context, and next move.
6. Copy suggested wording when the agent decides drafting is useful.
7. See recent negotiation sessions in a local dashboard.

Local browser storage is acceptable for MVP. If a user clears browser data, their history disappears.

Expand Down Expand Up @@ -58,6 +61,12 @@ Main current problems:
- `npm run lint` is defined but ESLint is not installed/configured.
- Current styling is purple/blue, not the intended v2 direction.

Important product boundary:

- v1/current site: static, content-heavy, script/generator lineage.
- v2/Haggly agent: interactive negotiation agent with memory, context gathering, and strategy-first behavior.
- Use v1 and the old markdown scripts as source material only. Do not make v2 a wrapper around canned scripts.

## Build Strategy

Use vertical slices. Do not build a huge frontend and huge backend separately.
Expand Down Expand Up @@ -267,13 +276,14 @@ Tasks:
- Add message composer.
- Add assistant/user message bubbles.
- Add loading state.
- Use mock AI responses first.
- Use mock Haggly-agent responses first.
- The mock assistant should diagnose and ask follow-up questions; it should not simply return canned Friendly/Firm/Casual scripts.
- Preserve copy-to-clipboard behavior.

Acceptance criteria:

- User can send a message.
- Assistant returns a mock response.
- Assistant returns mock agent guidance with leverage read, missing context, and next move.
- Messages stay visible in the current session.
- No backend or API key required.

Expand Down
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"test": "vitest run",
"test:e2e": "playwright test",
"preview": "vite preview"
},
"dependencies": {
Expand All @@ -17,6 +18,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^6.0.1",
Expand Down
24 changes: 24 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 3000 --strictPort',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
36 changes: 6 additions & 30 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,33 @@ import { useState } from 'react'
import LandingPage from './pages/LandingPage'
import BuyerPage from './pages/BuyerPage'
import SellerPage from './pages/SellerPage'
import { generateMessages } from './utils/messageGenerator'

function App() {
const [userType, setUserType] = useState(null) // 'buyer' or 'seller'
const [responses, setResponses] = useState(null)
const [toast, setToast] = useState(null)
const [isGenerating, setIsGenerating] = useState(false)

const handleGenerate = (formData) => {
setIsGenerating(true)

// Small delay for visual feedback
setTimeout(() => {
const messages = generateMessages(formData)
setResponses(messages)
setIsGenerating(false)

// Scroll to results on mobile
setTimeout(() => {
document.getElementById('results')?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}, 300)
}

const showToast = (message) => {
setToast(message)
setTimeout(() => setToast(null), 2000)
}

const handleReset = () => {
setResponses(null)
setUserType(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
}

if (!userType) {
return <LandingPage onSelectUserType={setUserType} />
}

if (userType === 'buyer') {
return <BuyerPage onBack={() => setUserType(null)} />
return (
<BuyerPage
onBack={() => setUserType(null)}
onCopy={showToast}
/>
)
}

return (
<SellerPage
responses={responses}
toast={toast}
isGenerating={isGenerating}
onBack={() => setUserType(null)}
onGenerate={handleGenerate}
onReset={handleReset}
onCopy={showToast}
/>
)
Expand Down
44 changes: 44 additions & 0 deletions src/features/chat/ChatComposer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Send } from 'lucide-react'
import { useState } from 'react'
import Button from '../../components/ui/Button'

function ChatComposer({ disabled, onSend }) {
const [draft, setDraft] = useState('')

const handleSubmit = (event) => {
event.preventDefault()
const message = draft.trim()

if (!message) {
return
}

onSend(message)
setDraft('')
}

return (
<form onSubmit={handleSubmit} className="border-t border-stone-200 bg-white p-3">
<div className="flex gap-2">
<label htmlFor="chat-message" className="sr-only">
Message Haggly
</label>
<textarea
id="chat-message"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Describe the situation, offer, pressure, and what you want..."
rows={2}
className="min-h-12 flex-1 resize-none rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm leading-6 text-stone-900 placeholder:text-stone-400 focus:border-primary-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-primary-500/20"
disabled={disabled}
/>
<Button type="submit" disabled={disabled || !draft.trim()} className="self-end">
<Send className="h-4 w-4" aria-hidden="true" />
Send
</Button>
</div>
</form>
)
}

export default ChatComposer
28 changes: 28 additions & 0 deletions src/features/chat/ChatMessage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import CopyButton from '../../components/CopyButton'

function ChatMessage({ message, onCopy }) {
const isAssistant = message.role === 'assistant'

return (
<article className={`flex ${isAssistant ? 'justify-start' : 'justify-end'}`}>
<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(' ')}
>
<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'}`}>
{isAssistant ? 'Haggly' : 'You'}
</span>
{isAssistant && <CopyButton text={message.content} onCopy={onCopy} />}
</div>
<p className="whitespace-pre-wrap text-sm leading-6">{message.content}</p>
</div>
</article>
)
}

export default ChatMessage
59 changes: 59 additions & 0 deletions src/features/chat/ChatPanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from 'react'
import Surface from '../../components/ui/Surface'
import { createInitialMessages, createMockAssistantReply } from './mockAssistant'
import ChatComposer from './ChatComposer'
import ChatMessage from './ChatMessage'

function ChatPanel({ mode, onCopy }) {
const [messages, setMessages] = useState(() => createInitialMessages(mode))
const [isReplying, setIsReplying] = useState(false)

const handleSend = (content) => {
const userMessage = {
id: `user-${Date.now()}`,
role: 'user',
content,
createdAt: Date.now(),
}

setMessages((currentMessages) => [...currentMessages, userMessage])
setIsReplying(true)

setTimeout(() => {
setMessages((currentMessages) => [
...currentMessages,
createMockAssistantReply({ mode, message: content }),
])
setIsReplying(false)
}, 450)
}

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>

<div className="max-h-[520px] min-h-[360px] space-y-4 overflow-y-auto bg-stone-50/70 p-4">
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
onCopy={() => onCopy('Copied to clipboard!')}
/>
))}

{isReplying && (
<div className="inline-flex rounded-full border border-primary-100 bg-white px-3 py-2 text-sm font-medium text-primary-800 shadow-sm shadow-stone-900/5">
Haggly is drafting...
</div>
)}
</div>

<ChatComposer disabled={isReplying} onSend={handleSend} />
</Surface>
)
}

export default ChatPanel
Loading