o2o-negosium-original/frontend/src/features/chat/components/UserButton.tsx
Mina Choi ac6366a898 [feat] negosium·negodata: 협상 진행 중 거부 + 결렬 건 최종 제출가로 직접 낙찰
협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고,
거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다.

negosium
- 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출,
  주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap
- 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다
- /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합
- 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업)

negodata
- 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부
- 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가)
- 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다
- 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…'

자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
2026-08-11 10:34:07 +09:00

150 lines
6.3 KiB
TypeScript

import { useEffect } from 'react'
import { useNavigate } from 'react-router'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs'
import { ExtraInfoBar } from '@/features/chat/components/ExtraInfoBar'
import { RejectForm } from '@/features/chat/components/RejectForm'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
import type { UserButtonConfig } from '@/features/chat/types'
// max-[1180px]: 채팅 폭이 좁아지면 버튼 패딩·최소폭 축소, 상품목록은 아이콘만 남긴다
const style = {
black:
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-brand-600 hover:bg-brand-700 rounded-xl items-center justify-center text-sm font-bold text-white shadow-sm cursor-pointer whitespace-nowrap transition-all duration-200 ease-out active:scale-[0.98]',
gray: 'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-neutral-60 hover:brightness-[0.97] rounded-xl items-center justify-center text-sm font-bold text-white cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
white:
'flex min-w-[120px] px-[28px] max-[1180px]:min-w-[100px] max-[1180px]:px-[20px] h-[46px] bg-white hover:bg-neutral-10 rounded-xl items-center justify-center text-sm font-bold text-neutral-80 border border-border cursor-pointer whitespace-nowrap transition-all ease-out active:scale-[0.98]',
}
// onReject: 협상 거부 진입점. 넘어오면 버튼 덱 끝에 붙는다(입력 단계는 인풋이 넓어 좁은 화면에서 아랫줄로 wrap).
export function UserButton({ type, text, textList, priceErrorMessage, onReject }: UserButtonConfig & { onReject?: () => void }) {
if (type === '') return null
// 부가정보 입력은 폼이라 가운데정렬 덱이 아니라 전체폭으로 편다. text = 저장 후 보낼 동의 문구.
if (type === 'extra-info') {
return (
<div className="w-full px-6 py-4 max-[1180px]:px-4">
<ExtraInfoBar proceedText={text || ''} />
</div>
)
}
// 결렬 통일 폼 — 부가정보와 같은 자리(액션바)에서 전체폭으로.
if (type === 'reject') {
return (
<div className="w-full px-6 py-4 max-[1180px]:px-4">
<RejectForm />
</div>
)
}
// 입력 단계는 에러 말풍선이 위로 삐져나가야 해서 overflow 클리핑 제외 (버튼 덱만 가로 스크롤 허용)
const isInputStep = type === 'percent' || type === 'price'
return (
<div
className={cn(
'relative flex w-full flex-wrap items-center justify-center gap-3 px-6 py-4 max-[1180px]:px-4',
!isInputStep && 'overflow-x-auto',
)}
>
<div className="flex items-center justify-center gap-3">
{type === 'one-black' && <OneBlack text={text || '확인'} />}
{type === 'one-gray' && <OneGray text={text || '확인'} />}
{type === 'black-white' && <BlackWhite textList={[textList?.[0] || '예', textList?.[1] || '아니오']} />}
{type === 'percent' && <Percent />}
{type === 'three-black' && (
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
)}
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <LoadingDots />}
</div>
{/* 오클릭 방지: 주 CTA 와 붙이지 않는다. 넓은 화면은 우측 끝 고정(가운데 CTA 는 그대로),
좁은 화면은 인풋 폭 때문에 같은 줄이 안 나오므로 wrap 되어 아랫줄로 내려간다. */}
{onReject && (
<button
className={cn(
style.white,
'min-[1180px]:absolute min-[1180px]:right-8 min-[1180px]:top-1/2 min-[1180px]:-translate-y-1/2',
)}
onClick={onReject}
>
협상 거부
</button>
)}
</div>
)
}
// agent 응답 대기 중(협상 중) 버튼 자리에 표시하는 "..." 점 애니메이션. 비활성(클릭 불가).
function LoadingDots() {
return (
<div className={cn(style.black, 'cursor-default pointer-events-none')} aria-label="협상 중" role="status">
<span className="flex gap-[6px]">
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.3s]" />
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.15s]" />
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce" />
</span>
</div>
)
}
function OneBlack({ text }: { text: string }) {
const navigate = useNavigate()
const sendMessage = useChatStore((s) => s.sendMessage)
const onClick = () => (text === GO_TO_LIST_TEXT ? navigate('/list') : sendMessage(text))
return (
<button className={style.black} onClick={onClick}>
{text}
</button>
)
}
function OneGray({ text }: { text: string }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<button className={style.gray} onClick={() => sendMessage(text)}>
{text}
</button>
)
}
function ThreeBlack({ textList }: { textList: [string, string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<div className="flex gap-3">
{textList.map((t) => (
<button key={t} className={style.black} onClick={() => sendMessage(t)}>
{t}
</button>
))}
</div>
)
}
function BlackWhite({ textList }: { textList: [string, string] }) {
const sendMessage = useChatStore((s) => s.sendMessage)
// Enter = 첫 버튼(예). 입력창이 없는 선택 단계라 전역 Enter 를 잡아도 안전하다.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault()
sendMessage(textList[0])
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [sendMessage, textList])
return (
<div className="flex gap-3">
<button className={style.black} onClick={() => sendMessage(textList[0])}>
{textList[0]}
</button>
<button className={style.white} onClick={() => sendMessage(textList[1])}>
{textList[1]}
</button>
</div>
)
}