[feat] negosium/front: 협상방 마감 정비 — 우측 패널 납작 섹션·모바일 협상절차 스텝바·이용가이드 모달 토스화·중복 이용가이드 제거·폰트 정리·마감시간 tabular-nums

This commit is contained in:
Mina Choi 2026-07-22 08:29:58 +09:00
parent e9427543c8
commit e7a51b1b75
10 changed files with 84 additions and 57 deletions

View File

@ -36,7 +36,7 @@ function ChatList() {
if (!chats || chats.length === 0) { if (!chats || chats.length === 0) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-base text-neutral-60">채팅 내역이 없습니다.</p> <p className="text-sm text-neutral-60">채팅 내역이 없습니다.</p>
</div> </div>
) )
} }

View File

@ -1,11 +1,13 @@
import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatStore } from '@/features/chat/stores/useChatStore'
import { ChatMessage } from '@/features/chat/components/ChatMessage' import { ChatMessage } from '@/features/chat/components/ChatMessage'
import { UserButton } from '@/features/chat/components/UserButton' import { UserButton } from '@/features/chat/components/UserButton'
import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
export function ChatSection() { export function ChatSection() {
const { userButtonConfig } = useChatStore() const { userButtonConfig } = useChatStore()
return ( return (
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface"> <div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
<MobileStepBar />
<ChatMessage /> <ChatMessage />
{/* 하단 액션 덱 */} {/* 하단 액션 덱 */}
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]"> <div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">

View File

@ -0,0 +1,46 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { STEPS } from '@/features/chat/components/menu/NegoStep'
// 모바일 전용: 협상절차를 채팅 상단에 가로 스텝바로 항상 표시한다. (데스크톱은 우측 패널 사용)
export function MobileStepBar() {
const chats = useChatStore((s) => s.messages)
const currentStep = chats[chats.length - 1]?.display_step || STEPS[0].name
const idx = Math.max(
0,
STEPS.findIndex((s) => s.name === currentStep),
)
return (
<div className="shrink-0 border-b border-border bg-white px-4 py-2.5 lg:hidden">
<div className="flex items-center">
{STEPS.map((step, i) => {
const done = i < idx
const current = i === idx
return (
<div key={step.name} className={cn('flex items-center', i < STEPS.length - 1 && 'flex-1')}>
<div
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors',
done && 'bg-success text-white',
current && 'bg-brand-600 text-white ring-4 ring-brand-light',
!done && !current && 'border border-border bg-white text-neutral-50',
)}
>
{done ? <Check className="size-3" strokeWidth={3} /> : i + 1}
</div>
{i < STEPS.length - 1 && (
<div className={cn('mx-1 h-0.5 flex-1 rounded-full', i < idx ? 'bg-success' : 'bg-neutral-20')} />
)}
</div>
)
})}
</div>
<p className="mt-1.5 truncate text-[11px] font-bold text-brand-600">
{idx + 1}. {STEPS[idx].name}
<span className="ml-1 font-normal text-neutral-50">{STEPS[idx].desc}</span>
</p>
</div>
)
}

View File

@ -20,7 +20,7 @@ export function RemainingTime() {
return ( return (
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold whitespace-nowrap', 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold whitespace-nowrap tabular-nums',
ended ? 'bg-neutral-20 text-neutral-60' : 'bg-brand-light text-brand-600', ended ? 'bg-neutral-20 text-neutral-60' : 'bg-brand-light text-brand-600',
)} )}
> >

View File

@ -1,33 +1,14 @@
import { useState } from 'react' // 헬프데스크 — 연락처. (안내 팝업 진입은 상단 '유의사항 및 이용방법' 섹션으로 일원화)
import { interactive, cn } from '@/lib'
import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup'
// 헬프데스크 — 이용 가이드 버튼 클릭 시 안내 팝업을 연다.
export function Contact() { export function Contact() {
const [isOpen, setIsOpen] = useState(false)
return ( return (
<section className="w-full pt-4"> <section className="w-full pt-4">
<div className="border-b border-border pb-2"> <div className="border-b border-border pb-2">
<span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span> <span className="text-[11px] font-bold tracking-wide text-neutral-60">헬프데스크</span>
</div> </div>
<div className="flex flex-col gap-2 pt-3"> <div className="flex flex-col gap-1 pt-3 text-sm text-neutral-60">
<button <span>010-0000-0000</span>
type="button" <span>o2odev@o2o.kr</span>
onClick={() => setIsOpen(true)}
className={cn(
'w-fit rounded-lg border border-border bg-white px-3 py-1.5 text-sm font-semibold text-neutral-70 hover:bg-neutral-10',
interactive,
)}
>
이용 가이드
</button>
<div className="flex flex-col gap-0.5 text-sm text-neutral-60">
<span>010-0000-0000</span>
<span>o2odev@o2o.kr</span>
</div>
</div> </div>
{isOpen && <ServiceGuidePopup onClose={() => setIsOpen(false)} />}
</section> </section>
) )
} }

View File

@ -7,7 +7,7 @@ interface Props {
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
} }
const STEPS = [ export const STEPS = [
{ name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' }, { name: '서비스안내', desc: '협상 방식과 유의사항을 확인합니다.' },
{ name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' }, { name: '담당자확인', desc: '협상 담당자 본인 여부를 확인합니다.' },
{ name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' }, { name: '협상품목안내', desc: '대상 품목과 기준 단가를 확인합니다.' },
@ -61,7 +61,7 @@ export function NegoStep({ isOpen, setIsOpen }: Props) {
<div className="min-w-0"> <div className="min-w-0">
<p <p
className={cn( className={cn(
'text-sm leading-tight', 'text-xs leading-tight',
current && 'font-extrabold text-brand-600', current && 'font-extrabold text-brand-600',
done && 'font-semibold text-success', done && 'font-semibold text-success',
!done && !current && 'font-medium text-neutral-80', !done && !current && 'font-medium text-neutral-80',
@ -69,7 +69,7 @@ export function NegoStep({ isOpen, setIsOpen }: Props) {
> >
{step.name} {step.name}
</p> </p>
<p className={cn('mt-0.5 text-[11px] leading-snug', current ? 'text-brand-600/80' : 'text-neutral-50')}> <p className={cn('mt-0.5 text-[10px] leading-snug', current ? 'text-brand-600/80' : 'text-neutral-50')}>
{step.desc} {step.desc}
</p> </p>
</div> </div>

View File

@ -3,14 +3,13 @@ import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문. // 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문.
// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다. // VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다.
// break-keep: 한국어 단어가 중간에서 잘려 줄바꿈되지 않도록 한다. const bodyStyle = 'text-sm font-normal leading-relaxed text-neutral-70 break-keep'
const textStyle = 'text-[17px] font-normal leading-[150%] tracking-[-0.34px] text-neutral-90 break-keep'
function Bullet({ children }: { children: ReactNode }) { function Bullet({ children }: { children: ReactNode }) {
return ( return (
<div className="flex items-start py-[6px]"> <div className="flex items-start gap-2.5 py-1.5">
<p className="ml-[8px] mr-[6px] flex-shrink-0">•</p> <span className="mt-[7px] size-1.5 shrink-0 rounded-full bg-brand-600" />
<div className={`${textStyle} flex-1`}>{children}</div> <div className={`${bodyStyle} flex-1`}>{children}</div>
</div> </div>
) )
} }
@ -23,24 +22,26 @@ export function GuideContent() {
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="mb-8 break-keep text-center text-lg font-bold text-neutral-90"> <div className="mb-5 break-keep text-center text-base font-bold text-neutral-90">
협상 유의 사항 및 서비스 이용 방법 안내 협상 유의 사항 및 서비스 이용 방법 안내
</div> </div>
<div className="flex flex-col items-start"> <div className={`${bodyStyle} mb-2`}>
<div className={`${textStyle} mb-2`}> 협상과 관련하여 다음 사항을 참고하여 견적에 참여해 주시기 바랍니다.
협상과 관련하여 다음 사항을 참고하여 견적에 참여해 주시기 바랍니다. </div>
</div>
<div className="flex flex-col divide-y divide-border/60 rounded-xl border border-border px-4 py-1">
<Bullet> <Bullet>
협상 개시는&nbsp; 협상 개시는&nbsp;
<span className="font-bold">Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작</span> <span className="font-semibold text-neutral-90">
Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작
</span>
됩니다. 됩니다.
</Bullet> </Bullet>
<Bullet> <Bullet>
부여된 협상 시간에&nbsp; 부여된 협상 시간에&nbsp;
<span className="font-bold"> <span className="font-semibold text-neutral-90">
응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행 응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행
</span> </span>
될 수 있습니다. 될 수 있습니다.
@ -48,25 +49,23 @@ export function GuideContent() {
<Bullet> <Bullet>
협상에 입력되는 모든 가격은&nbsp; 협상에 입력되는 모든 가격은&nbsp;
<span className="font-bold text-negative"> <span className="font-semibold text-negative">
{vat} 및 {deliveryFee} {vat} 및 {deliveryFee}
</span> </span>
&nbsp;기준이며,&nbsp; &nbsp;기준이며,&nbsp;
<span className="font-bold"> <span className="font-semibold text-neutral-90">
할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산 할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산
</span> </span>
되어 제시됩니다. 되어 제시됩니다.
</Bullet> </Bullet>
<Bullet> <Bullet>
본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 얻어진 결과나 내용에
얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 전제로 진행됩니다.
전제로 진행됩니다.
</Bullet> </Bullet>
<Bullet> <Bullet>
협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 바랍니다.
바랍니다.
</Bullet> </Bullet>
<Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet> <Bullet>안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다.</Bullet>
@ -78,13 +77,12 @@ export function GuideContent() {
// 하단 문의 안내 박스 // 하단 문의 안내 박스
export function GuideContactBox() { export function GuideContactBox() {
return ( return (
<div className="flex w-full flex-col items-center gap-2 rounded-[16px] bg-neutral-10 p-6"> <div className="flex w-full flex-col items-center gap-1.5 rounded-xl bg-neutral-10 p-4">
<div className={`${textStyle} text-center`}> <div className={`${bodyStyle} text-center`}>
기타 협상과정에서 궁금한 사항이나 문의하실 사항은 아래의 연락처로 상담을 부탁드립니다. 기타 협상 과정에서 궁금하거나 문의하실 사항은 아래 연락처로 상담 부탁드립니다.
</div> </div>
{/* textStyle 의 font-normal 과 같은 요소에서 충돌하지 않도록 bold 는 별도 조합으로 준다 */} <div className="break-keep text-center text-sm font-bold text-neutral-90">
<div className="break-keep text-center text-[17px] font-bold leading-[150%] tracking-[-0.34px] text-neutral-90"> 헬프데스크 010-0000-0000 · o2odev@o2o.kr
헬프데스크 010-0000-0000, 이메일: o2odev@o2o.kr
</div> </div>
</div> </div>
) )

View File

@ -53,7 +53,7 @@ export function RejectCM() {
return ( return (
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300"> <div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-base font-bold text-[#E5484D]"> <div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" /> <AlertTriangle className="size-5 shrink-0" />
최종 공급 희망 가격 입력 최종 공급 희망 가격 입력
</div> </div>

View File

@ -82,7 +82,7 @@ export function RejectRSP() {
return ( return (
<div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300"> <div className="flex flex-col w-full p-8 bg-white rounded-2xl border border-border shadow-sm gap-2 transition-all duration-300">
<div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-base font-bold text-[#E5484D]"> <div className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#FFF1F1] px-4 py-3 text-sm font-bold text-[#E5484D]">
<AlertTriangle className="size-5 shrink-0" /> <AlertTriangle className="size-5 shrink-0" />
협상 합의 불가 사유 및 최종 제안 단가 입력 협상 합의 불가 사유 및 최종 제안 단가 입력
</div> </div>

View File

@ -32,13 +32,13 @@ function InputWithUnit({
value={value} value={value}
onChange={onChange} onChange={onChange}
onKeyDown={(e) => e.key === 'Enter' && onSubmit()} onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
className="h-[52px] flex-1 bg-neutral-10 rounded-xl outline-none border-2 border-brand-600 text-lg font-bold text-neutral-90 placeholder:text-sm placeholder:font-normal placeholder:text-neutral-50 pl-4 pr-[124px]" className="h-[52px] flex-1 bg-neutral-10 rounded-xl outline-none border-2 border-brand-600 text-base font-bold text-neutral-90 placeholder:text-sm placeholder:font-normal placeholder:text-neutral-50 pl-4 pr-[124px]"
placeholder={placeholder} placeholder={placeholder}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={error ? errorId : undefined} aria-describedby={error ? errorId : undefined}
/> />
<div className="absolute left-4 text-lg font-bold text-neutral-70 pointer-events-none"> <div className="absolute left-4 text-base font-bold text-neutral-70 pointer-events-none">
<span className="opacity-0">{value}</span> <span className="opacity-0">{value}</span>
{value && <span className="ml-1">{unit}</span>} {value && <span className="ml-1">{unit}</span>}
</div> </div>