[fix] negosium·negodata·landing: 채팅 진입·부가정보 저장 오류 수정, 모바일 여백·스크롤·노치 대응

- 채팅 링크로 바로 진입하면 세션이 협상생성(1)에 머물러 오프닝 메시지가 seed 되지 않던 문제:
  chat init/messages 에서 participate 와 동일하게 협상중(2)으로 전이(마감 견적·마감시간 초과는 제외)
- 부가정보 저장이 1301 로 실패하던 문제: '협상완료' 요약은 chat_end=false 라 세션이 아직 협상중이므로,
  마지막 말풍선이 summaryRSP/CM 이면 협상중 세션도 저장 허용
- 채팅 언마운트 시 messages 캐시도 제거 — staleTime:Infinity 라 빈 목록이 남아 재진입해도 빈 화면이던 문제
- 채팅 스크롤을 컨테이너 기준 scrollTo 로 변경(rAF 후 실행) — 요약·폼이 뒤늦게 레이아웃되면 중간에 멈추던 문제
- 모바일 채팅 상단 여백 제거(pt-[64px] 는 우측 협상절차 카드가 있는 lg 이상만)
- 노치 대응: viewport-fit=cover + safe-t/b/x 유틸 신설, 헤더·하단 액션 덱·드로어·랜딩 헤더에 적용, h-screen→100dvh
This commit is contained in:
Mina Choi 2026-07-23 12:50:40 +09:00
parent 0f10b6a70f
commit db07a60cfa
16 changed files with 144 additions and 30 deletions

View File

@ -209,6 +209,8 @@ class ChatService:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
await self._ensure_in_progress(sess, quote)
res.session_id = str(sess.session_id)
res.session_status = sess.status
res.quotation_id = str(sess.quotation_id)
@ -229,6 +231,33 @@ class ChatService:
res.custom = sess.custom or {}
return res
async def _ensure_in_progress(self, sess, quote) -> None:
"""협상생성(1) 세션을 채팅 진입만으로 협상중(2)으로 전이한다(participate 와 동일 전이).
negodata 안내 메일/링크는 목록의 참여 버튼을 거치지 않고 chat 으로 바로 들어오는데,
오프닝 메시지는 협상중일 때만 seed 되므로 전이가 없으면 채팅으로 멈춘다.
마감된 견적은 진입해도 대화가 불가하므로 전이하지 않는다."""
if sess.status != SessionStatus.CREATED.value:
return
if quote is None or quote.status == QuotationStatus.CLOSED.value:
return
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc):
return
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
],
)
if err_type != ErrorType.SUCCESS:
return
sess.status = SessionStatus.IN_PROGRESS.value
# ---- messages -------------------------------------------------------
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
res = Res_ChatMessages()
@ -245,6 +274,15 @@ class ChatService:
res.result.SetResult(err_type)
return res
# 협상생성 상태로 바로 진입한 경우(메일 링크) 여기서도 전이한다 —
# init 과 병렬로 호출돼 init 의 전이를 못 본 채 읽었을 수 있다.
if not rows and sess.status == SessionStatus.CREATED.value:
_, quote = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
await self._ensure_in_progress(sess, quote)
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
opening = await self._seed_opening(sess)

View File

@ -4,9 +4,10 @@ from datetime import datetime, timezone
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import sessions
from common.database.model.models import chats, sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
from services.auth_service import AuthService
@ -18,9 +19,18 @@ class NegotiationService:
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
"""
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)):
# 부가정보 입력 폼을 띄우는 요약 말풍선 종류. 이 말풍선이 나온 뒤면 협상은 타결된 것으로 본다.
_SUMMARY_BOT_TYPES = ("summaryRSP", "summaryCM")
def __init__(
self,
auth: AuthService = Depends(AuthService),
session_crud: ISessionCRUD = Depends(SessionCRUD),
chat_crud: IChatCRUD = Depends(ChatCRUD),
):
self.auth = auth
self.session_crud = session_crud
self.chat_crud = chat_crud
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
res = Res_SessionList()
@ -104,10 +114,13 @@ class NegotiationService:
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
return res
# 3) 협상완료(타결) 세션만 부가정보 입력 허용
# 3) 협상완료(타결) 세션만 부가정보 입력 허용.
# 단 '협상완료' 요약 말풍선은 chat_end=false 라 세션이 아직 협상중(2)이다
# (동의 → '협상종료' 턴에서야 완료로 전이). 폼은 요약 시점에 뜨므로 그 구간도 허용한다.
if sess.status != SessionStatus.DONE.value:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
if sess.status != SessionStatus.IN_PROGRESS.value or not await self._is_after_summary(sess.session_id):
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
# 4) 저장(supplier_id 가드 crud)
err_type = await DB_SESSION_MNG.execute_lambda_run(
@ -121,6 +134,16 @@ class NegotiationService:
res.session_id = str(session_id)
return res
async def _is_after_summary(self, session_id) -> bool:
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, session_id),
)
if err_type != ErrorType.SUCCESS or not last_meta:
return False
return last_meta.get("bot_chat_type") in self._SUMMARY_BOT_TYPES
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
성공 (SUCCESS, sess, quote), 실패 (err_type, None, None) 반환한다.

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bot.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>AI 가격 협상 솔루션</title>
</head>
<body>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, memo } from 'react'
import { useEffect, useRef, memo, type RefObject } from 'react'
import { useMeQuery } from '@/apis'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
@ -14,24 +14,40 @@ import { RejectCM } from '@/features/chat/components/templates/RejectCM'
const AI_LABEL = '아이마켓코리아 (구매 MD)'
export function ChatMessage() {
const scrollRef = useRef<HTMLDivElement | null>(null)
return (
<div className="flex-1 w-full min-h-0">
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]">
<ChatList />
{/* lg:pt-[64px]: lg
.
*/}
<div
ref={scrollRef}
className="chat-scroll h-full overflow-y-auto flex flex-col pt-4 lg:pt-[64px] pb-6 px-4 sm:px-6 min-[1180px]:pl-[80px] min-[1180px]:pr-[72px] min-[1350px]:pl-[140px] min-[1350px]:pr-[126px]"
>
<ChatList scrollRef={scrollRef} />
</div>
</div>
)
}
function ChatList() {
const bottomRef = useRef<HTMLDivElement | null>(null)
function ChatList({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }) {
const isFirstRef = useRef(true)
const chats = useChatStore((s) => s.messages)
const isLoading = useChatStore((s) => s.isLoading)
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤.
// 스크롤 컨테이너를 직접 내린다 — scrollIntoView 는 요약/부가정보 폼처럼 큰 블록이 뒤늦게
// 레이아웃되면 애니메이션 도중 목표 위치가 밀려 중간에 멈춘다.
// 진입 첫 렌더는 즉시(auto) — 히스토리를 복원하며 위에서부터 훑어 내려가는 연출을 막는다.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
const scroller = scrollRef.current
if (!scroller) return
const behavior: ScrollBehavior = isFirstRef.current ? 'auto' : 'smooth'
isFirstRef.current = false
const id = requestAnimationFrame(() => {
scroller.scrollTo({ top: scroller.scrollHeight, behavior })
})
return () => cancelAnimationFrame(id)
}, [chats, isLoading])
if (!chats || chats.length === 0) {
@ -48,7 +64,6 @@ function ChatList() {
<MessageItem key={message.chat_id || index} message={message} messages={chats} currentIndex={index} />
))}
{isLoading && <TypingBubble />}
<div ref={bottomRef} />
</div>
)
}

View File

@ -10,7 +10,8 @@ export function ChatSection() {
<MobileStepBar />
<ChatMessage />
{/* 하단 액션 덱 */}
<div className="shrink-0 border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
<UserButton {...userButtonConfig} />
</div>
</div>

View File

@ -31,7 +31,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
side === 'left' ? 'left-0' : 'right-0',
)}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-5">
<div className="flex h-14 box-content safe-t shrink-0 items-center justify-between border-b border-border px-5">
<span className="text-base font-bold text-neutral-90">{title}</span>
<button
type="button"
@ -42,7 +42,7 @@ export function MobileDrawer({ open, onClose, side = 'left', title, children }:
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto safe-b">{children}</div>
</div>
</div>,
document.body,

View File

@ -160,6 +160,9 @@ export function useChatController(sessionId: string) {
useChatInitStore.getState().reset()
// init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다.
queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) })
// messages 는 staleTime:Infinity 라 캐시가 남으면 재진입해도 다시 안 부른다 —
// 오프닝 생성 전(협상생성 상태)에 받은 빈 목록이 눌러앉아 채팅이 빈 화면으로 굳는다.
queryClient.removeQueries({ queryKey: chatKeys.messages(sessionId) })
}
}, [sessionId, sendMutate, navigate, queryClient])

View File

@ -162,6 +162,17 @@
}
}
/* 노치/ 인디케이터 회피 (viewport-fit=cover ). 안전영역이 0 기기에선 아무 영향 없다.
화면 / 아래에 붙는 바에만 붙인다 스크롤되는 본문에는 쓰지 않는다. */
@layer components {
.safe-t { padding-top: env(safe-area-inset-top); }
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
/* 타이포그래피 스케일 (headline / title / body / reject) */
@layer components {
.headline-3 { font-size: 24px; font-weight: 700; line-height: 30px; letter-spacing: -0.48px; }

View File

@ -1,8 +1,9 @@
import { type ReactNode } from 'react'
import { cn } from '@/lib'
// safe-t: 노치 기기에서 헤더가 상태바 밑으로 파고들지 않도록 안전영역만큼 키운다(h-[56px] 은 콘텐츠 높이).
const HEADER_BASE =
'flex w-full h-[56px] items-center bg-white text-foreground border-b border-border'
'flex w-full h-[56px] box-content safe-t items-center bg-white text-foreground border-b border-border'
const HEADER_ALIGN = {
left: 'justify-start',

View File

@ -13,14 +13,14 @@ const SIDEBAR_WIDTH = {
} as const
const styles = {
root: 'flex w-full min-h-screen max-h-screen bg-background',
scrollX: 'flex w-full min-h-screen max-h-screen overflow-x-auto overflow-y-hidden',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1024px] bg-background',
root: 'flex w-full min-h-[100dvh] max-h-[100dvh] bg-background safe-x',
scrollX: 'flex w-full min-h-[100dvh] max-h-[100dvh] overflow-x-auto overflow-y-hidden safe-x',
scrollXInner: 'flex w-full min-h-[100dvh] max-h-[100dvh] min-w-[1024px] bg-background',
// 좌측 레일은 lg 미만에서 숨기고(드로어로 대체), main 이 전체 폭을 차지한다. 보고서식 풀블리드 + 경계선.
sidebar: 'hidden lg:flex flex-col h-screen bg-white border-r border-border',
sidebar: 'hidden lg:flex flex-col h-[100dvh] bg-white border-r border-border',
logoHeader:
'flex w-full h-[56px] pl-[24px] pr-[16px] items-center justify-between shrink-0 border-b border-border',
main: 'flex flex-1 flex-col h-screen overflow-hidden',
main: 'flex flex-1 flex-col h-[100dvh] overflow-hidden',
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden bg-surface',
} as const

View File

@ -75,3 +75,14 @@ body {
filter: blur(100px);
pointer-events: none;
}
/* 노치/ 인디케이터 회피 (viewport-fit=cover ). 안전영역이 0 기기에선 아무 영향 없다.
화면 / 아래에 붙는 바에만 붙인다 스크롤되는 본문에는 쓰지 않는다. */
@layer components {
.safe-t { padding-top: env(safe-area-inset-top); }
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}

View File

@ -17,7 +17,7 @@ export function Header() {
return (
<header
className={`fixed top-0 left-0 right-0 z-40 transition-all duration-300 ${
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${
scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent"
}`}
>

View File

@ -15,7 +15,7 @@ export function Layout({ children }: { children: ReactNode }) {
<html lang="ko">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<Meta />
<Links />
</head>

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>NegoData</title>
</head>
<body>

View File

@ -124,7 +124,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
};
return (
<div className="min-h-screen bg-background text-foreground flex">
<div className="min-h-[100dvh] safe-x bg-background text-foreground flex">
{/* 토스트는 sonner <Toaster/>(main.tsx)가 전역으로 처리한다 */}
{/* 모바일 드로어 백드롭 */}
@ -273,7 +273,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
)}
>
{/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */}
<header className="h-14 bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
<header className="h-14 box-content safe-t bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* 모바일 햄버거 */}
<Button

View File

@ -64,3 +64,14 @@ body {
55% { opacity: 0.95; transform: rotate(-12deg) scale(0.9); }
100% { opacity: 0.75; transform: rotate(-12deg) scale(1); }
}
/* 노치/ 인디케이터 회피 (viewport-fit=cover ). 안전영역이 0 기기에선 아무 영향 없다.
화면 / 아래에 붙는 바에만 붙인다 스크롤되는 본문에는 쓰지 않는다. */
@layer components {
.safe-t { padding-top: env(safe-area-inset-top); }
.safe-b { padding-bottom: env(safe-area-inset-bottom); }
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}