협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고, 거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다. negosium - 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출, 주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap - 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다 - /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합 - 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업) negodata - 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부 - 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가) - 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다 - 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…' 자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
150 lines
6.3 KiB
TypeScript
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>
|
|
)
|
|
}
|