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
151 changes: 151 additions & 0 deletions src/features/negotiation/negotiationContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
export const OFFER_TYPES = {
unknown: 'unknown',
lowball: 'lowball',
counter: 'counter',
close: 'close',
acceptable: 'acceptable',
accept: 'accept',
}

function normalizeMode(mode) {
return mode === 'buyer' ? 'buyer' : 'seller'
}

function normalizePrice(price) {
if (price === null || price === undefined || price === '') {
return null
}

const value = typeof price === 'string' ? Number(price.replace(/[$,]/g, '')) : Number(price)
return Number.isFinite(value) && value >= 0 ? value : null
}

export function formatPrice(price) {
const value = normalizePrice(price)
if (value === null) {
return ''
}

return value % 1 === 0 ? `$${value}` : `$${value.toFixed(2)}`
}

export function classifyOffer({ askingPrice, theirOffer, minimumPrice } = {}) {
const asking = normalizePrice(askingPrice)
const offer = normalizePrice(theirOffer)
const minimum = normalizePrice(minimumPrice)

if (!asking || offer === null) {
return OFFER_TYPES.unknown
}

if (offer >= asking) {
return OFFER_TYPES.accept
}

if (minimum !== null && offer >= minimum) {
return OFFER_TYPES.acceptable
}

const offerPercentage = (offer / asking) * 100

if (offerPercentage < 50) {
return OFFER_TYPES.lowball
}

if (offerPercentage < 75) {
return OFFER_TYPES.counter
}

return OFFER_TYPES.close
}

export function calculateCounterOffer({ askingPrice, theirOffer, minimumPrice } = {}) {
const asking = normalizePrice(askingPrice)
const offer = normalizePrice(theirOffer)
const minimum = normalizePrice(minimumPrice)

if (!asking || offer === null) {
return null
}

const midpoint = Math.round((offer + asking) / 2)

if (minimum !== null) {
return Math.max(midpoint, minimum)
}

return Math.max(midpoint, Math.round(asking * 0.85))
}

function getPercentOfAsking(askingPrice, theirOffer) {
if (!askingPrice || theirOffer === null) {
return null
}

return Math.round((theirOffer / askingPrice) * 100)
}

function getMissingFields({ item, askingPrice, theirOffer }) {
return [
!item ? 'item' : null,
!askingPrice ? 'askingPrice' : null,
theirOffer === null ? 'theirOffer' : null,
].filter(Boolean)
}

function getNextMove(offerType) {
if (offerType === OFFER_TYPES.accept || offerType === OFFER_TYPES.acceptable) {
return 'accept-or-tighten-logistics'
}

if (offerType === OFFER_TYPES.close) {
return 'counter-lightly'
}

if (offerType === OFFER_TYPES.counter || offerType === OFFER_TYPES.lowball) {
return 'test-seriousness-then-counter'
}

return 'gather-context'
}

export function buildNegotiationContext({
mode = 'seller',
item,
askingPrice,
theirOffer,
minimumPrice,
} = {}) {
const normalizedItem = item?.trim() || 'item'
const normalizedAskingPrice = normalizePrice(askingPrice)
const normalizedTheirOffer = normalizePrice(theirOffer)
const normalizedMinimumPrice = normalizePrice(minimumPrice)
const offerType = classifyOffer({
askingPrice: normalizedAskingPrice,
theirOffer: normalizedTheirOffer,
minimumPrice: normalizedMinimumPrice,
})

return {
mode: normalizeMode(mode),
item: normalizedItem,
offerType,
prices: {
askingPrice: normalizedAskingPrice,
theirOffer: normalizedTheirOffer,
minimumPrice: normalizedMinimumPrice,
counterOffer: calculateCounterOffer({
askingPrice: normalizedAskingPrice,
theirOffer: normalizedTheirOffer,
minimumPrice: normalizedMinimumPrice,
}),
percentOfAsking: getPercentOfAsking(normalizedAskingPrice, normalizedTheirOffer),
},
missingFields: getMissingFields({
item,
askingPrice: normalizedAskingPrice,
theirOffer: normalizedTheirOffer,
}),
nextMove: getNextMove(offerType),
}
}
99 changes: 99 additions & 0 deletions src/features/negotiation/negotiationContext.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import {
buildNegotiationContext,
calculateCounterOffer,
classifyOffer,
formatPrice,
} from './negotiationContext'

describe('classifyOffer', () => {
it('classifies an offer below 50% of asking as a lowball', () => {
expect(classifyOffer({ askingPrice: 200, theirOffer: 50 })).toBe('lowball')
})

it('classifies an offer between 50% and 75% as counter territory', () => {
expect(classifyOffer({ askingPrice: 200, theirOffer: 130 })).toBe('counter')
})

it('classifies an offer at 75% or higher of asking as close', () => {
expect(classifyOffer({ askingPrice: 200, theirOffer: 170 })).toBe('close')
})

it('treats an offer at or above the minimum as acceptable before countering', () => {
expect(classifyOffer({ askingPrice: 200, theirOffer: 150, minimumPrice: 140 })).toBe(
'acceptable',
)
})

it('treats an offer at or above asking as a full accept', () => {
expect(classifyOffer({ askingPrice: 200, theirOffer: 200 })).toBe('accept')
})
})

describe('calculateCounterOffer', () => {
it('counters at the higher of midpoint or 85% of asking when no minimum is set', () => {
expect(calculateCounterOffer({ askingPrice: 100, theirOffer: 30 })).toBe(85)
})

it('never counters below the minimum when one is set', () => {
expect(calculateCounterOffer({ askingPrice: 200, theirOffer: 80, minimumPrice: 150 })).toBe(
150,
)
})
})

describe('formatPrice', () => {
it('formats whole-dollar and decimal prices consistently', () => {
expect(formatPrice(85)).toBe('$85')
expect(formatPrice(85.5)).toBe('$85.50')
})
})

describe('buildNegotiationContext', () => {
it('creates structured seller context for future prompt assembly', () => {
const context = buildNegotiationContext({
mode: 'seller',
item: ' gaming chair ',
askingPrice: 200,
theirOffer: 130,
minimumPrice: 120,
})

expect(context).toMatchObject({
mode: 'seller',
item: 'gaming chair',
offerType: 'acceptable',
prices: {
askingPrice: 200,
theirOffer: 130,
minimumPrice: 120,
counterOffer: 165,
percentOfAsking: 65,
},
nextMove: 'accept-or-tighten-logistics',
})
})

it('keeps incomplete buyer context usable without fake price assumptions', () => {
const context = buildNegotiationContext({
mode: 'buyer',
item: 'used pickup',
askingPrice: 12000,
})

expect(context).toMatchObject({
mode: 'buyer',
item: 'used pickup',
offerType: 'unknown',
prices: {
askingPrice: 12000,
theirOffer: null,
minimumPrice: null,
counterOffer: null,
percentOfAsking: null,
},
missingFields: ['theirOffer'],
nextMove: 'gather-context',
})
})
})
68 changes: 10 additions & 58 deletions src/utils/messageGenerator.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,14 @@
/**
* Message Generator for Haggly
*
* Generates negotiation responses based on the offer type and user preferences.
* Templates are based on real-world negotiation best practices.
*/

/**
* Determine the type of offer based on the numbers
*/
function getOfferType(askingPrice, theirOffer, minimumPrice) {
const offerPercentage = (theirOffer / askingPrice) * 100

// If they offered at or above asking price - accept!
if (theirOffer >= askingPrice) {
return 'accept'
}

// If minimum is set and offer meets it
if (minimumPrice && theirOffer >= minimumPrice) {
return 'acceptable'
}

// If offer is less than 50% - lowball
if (offerPercentage < 50) {
return 'lowball'
}

// If offer is between 50-75% - counter territory
if (offerPercentage < 75) {
return 'counter'
}

// If offer is 75%+ - close, could accept or counter slightly
return 'close'
}

/**
* Calculate a smart counter offer
*/
function getCounterOffer(askingPrice, theirOffer, minimumPrice) {
// If minimum is set, counter halfway between their offer and asking (but not below minimum)
if (minimumPrice) {
const midpoint = Math.round((theirOffer + askingPrice) / 2)
return Math.max(midpoint, minimumPrice)
}

// Default: counter at 85-90% of asking or midpoint, whichever is higher
const midpoint = Math.round((theirOffer + askingPrice) / 2)
const eightyFive = Math.round(askingPrice * 0.85)
return Math.max(midpoint, eightyFive)
}
import {
calculateCounterOffer,
classifyOffer,
formatPrice,
} from '../features/negotiation/negotiationContext'

/**
* Format currency nicely
* Message Generator for Haggly
*
* Generates deterministic fallback responses based on structured negotiation logic.
*/
function formatPrice(price) {
return price % 1 === 0 ? `$${price}` : `$${price.toFixed(2)}`
}

/**
* Message templates organized by offer type and tone
Expand Down Expand Up @@ -157,8 +109,8 @@ const templates = {
* Generate messages for all three tones
*/
export function generateMessages({ item, askingPrice, theirOffer, minimumPrice }) {
const offerType = getOfferType(askingPrice, theirOffer, minimumPrice)
const counterOffer = getCounterOffer(askingPrice, theirOffer, minimumPrice)
const offerType = classifyOffer({ askingPrice, theirOffer, minimumPrice })
const counterOffer = calculateCounterOffer({ askingPrice, theirOffer, minimumPrice })

const replacements = {
'{item}': item,
Expand Down