diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index bca3a1a..366ce44 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -29,6 +29,7 @@ class supplier_users(MAIN_BASE): last_accessed_at = Column(DateTime(timezone=True), nullable=False) # 마지막 접속 시각 status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive role = Column(SmallInteger, nullable=False, server_default=text("1")) # 권한: 1=user, 2=manager + hide_service_info = Column(Boolean, nullable=False, server_default=text("false")) # 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부 created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 diff --git a/backend/crud/user_crud.py b/backend/crud/user_crud.py index 88af505..8d708bf 100644 --- a/backend/crud/user_crud.py +++ b/backend/crud/user_crud.py @@ -56,6 +56,14 @@ class IUserCRUD(ABC): async def update_last_accessed(self, cdb: AsyncSession, su_id) -> ErrorType: pass + @abstractmethod + async def get_hide_service_info(self, cdb: AsyncSession, su_id) -> Tuple[ErrorType, bool]: + pass + + @abstractmethod + async def set_hide_service_info(self, cdb: AsyncSession, su_id) -> ErrorType: + pass + class UserCRUD(IUserCRUD): async def get_account_by_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, supplier_users]: @@ -195,3 +203,34 @@ class UserCRUD(IUserCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def get_hide_service_info(self, cdb: AsyncSession, su_id) -> Tuple[ErrorType, bool]: + # 서비스 안내 팝업 "안내 보지 않기" 여부만 조회한다. + try: + query = ( + select(supplier_users.hide_service_info) + .where(supplier_users.su_id == su_id, supplier_users.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_hide_service_info(su_id:{su_id}) failed.") + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, bool(row_list[0]) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def set_hide_service_info(self, cdb: AsyncSession, su_id) -> ErrorType: + # "안내 보지 않기" 는 켜기만 있다(해제 API 없음). + try: + query = ( + update(supplier_users) + .where(supplier_users.su_id == su_id, supplier_users.deleted == False) # noqa: E712 + .values(hide_service_info=True) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/backend/router/v1/auth/account.py b/backend/router/v1/auth/account.py index 623924c..80828cc 100644 --- a/backend/router/v1/auth/account.py +++ b/backend/router/v1/auth/account.py @@ -9,7 +9,18 @@ from router.v1.validator.dependencies import ( security, ) from services.auth_service import AuthService -from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Logout, Res_Me, Res_RefreshToken +from .protocol import ( + Req_CreateAccount, + Req_HidePopup, + Req_Login, + Res_CreateAccount, + Res_HidePopup, + Res_Login, + Res_Logout, + Res_Me, + Res_PopupStatus, + Res_RefreshToken, +) # 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당. router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}}) @@ -54,6 +65,35 @@ async def logout(user_info: UserInfo = Depends(IsValidAccessToken), service: Aut return RemoveNoneResponse(await service.logout(user_info)) +@router.get( + path="/popup/status", + response_model=Res_PopupStatus, + summary="팝업 숨김 상태 조회", + description="유저별 팝업 '안내 보지 않기' 저장 상태를 반환한다. 현재는 service_info(협상 유의사항 안내) 하나.", +) +async def popup_status( + user_info: UserInfo = Depends(IsValidAccessToken), + credentials: HTTPAuthorizationCredentials = Depends(security), + service: AuthService = Depends(), +): + return RemoveNoneResponse(await service.popup_status(user_info, credentials.credentials)) + + +@router.post( + path="/popup/hide", + response_model=Res_HidePopup, + summary="팝업 안내 보지 않기", + description="해당 팝업을 다시 표시하지 않도록 유저에 영구 저장한다. popup_type: service_info", +) +async def hide_popup( + req: Req_HidePopup, + user_info: UserInfo = Depends(IsValidAccessToken), + credentials: HTTPAuthorizationCredentials = Depends(security), + service: AuthService = Depends(), +): + return RemoveNoneResponse(await service.hide_popup(user_info, credentials.credentials, req.popup_type)) + + @router.get( path="/me", response_model=Res_Me, diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index 687efe2..3bce56f 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -52,3 +52,15 @@ class Res_Me(Res_WebPacketProtocol): class Res_Logout(Res_WebPacketProtocol): pass + + +class Res_PopupStatus(Res_WebPacketProtocol): + service_info: bool = Field(False, description="협상 유의사항(서비스 안내) 팝업 '안내 보지 않기' 여부") + + +class Req_HidePopup(AuthProtocol): + popup_type: str = Field("", description="숨길 팝업 종류: service_info") + + +class Res_HidePopup(Res_WebPacketProtocol): + pass diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index f95f114..2b0a829 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -10,7 +10,15 @@ from common.models.gmodel import UserInfo from common.utils.gtime import GTime from config.server_configs import jwt_token_config from crud.user_crud import IUserCRUD, UserCRUD -from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_Logout, Res_Me, Res_RefreshToken +from router.v1.auth.protocol import ( + Res_CreateAccount, + Res_HidePopup, + Res_Login, + Res_Logout, + Res_Me, + Res_PopupStatus, + Res_RefreshToken, +) from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW @@ -244,6 +252,45 @@ class AuthService: res.role = info.role return res + async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: + # 유저별 팝업 숨김 상태 조회. 현재는 서비스 안내(service_info) 팝업 하나만 관리한다. + res = Res_PopupStatus() + err_type, info = await self.authenticate(user_info, access_token) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type, hidden = await DB_SESSION_MNG.execute_lambda( + supplier_users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_hide_service_info(s, uuid.UUID(info.su_id)), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.service_info = hidden + return res + + async def hide_popup(self, user_info: UserInfo, access_token: str, popup_type: str) -> Res_HidePopup: + # "안내 보지 않기" 영구 저장. 팝업 종류가 늘면 popup_type 분기를 추가한다. + res = Res_HidePopup() + if popup_type != "service_info": + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + + err_type, info = await self.authenticate(user_info, access_token) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_users.DBType()], + [lambda s: self.user_crud.set_hide_service_info(s, uuid.UUID(info.su_id))], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + async def logout(self, user_info: UserInfo) -> Res_Logout: # 해당 유저의 저장 토큰(access/refresh)을 모두 삭제 → 이후 보호 요청·재발급이 차단된다. res = Res_Logout() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 275ba7c..696c465 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -322,3 +322,52 @@ async def test_relogin_invalidates_previous_access(client, account_seed): # 새 access → 정상 r_new = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"}) assert r_new.json()["result"]["success"] is True + + +# ---- 팝업 숨김 (popup/status, popup/hide) -------------------------------------- +async def test_popup_status_default_false(client, account_seed): + # 신규 유저는 숨김 상태가 아니다. + access = (await _login(client)).json()["access_token"] + r = await client.get("/v1/auth/popup/status", headers={"Authorization": f"Bearer {access}"}) + assert r.status_code == 200 + body = r.json() + assert body["result"]["success"] is True + assert body["service_info"] is False + + +async def test_popup_hide_persists(client, account_seed): + # hide 후 status 가 True 로 바뀌고, 재로그인해도 유지된다(유저 행에 영구 저장). + access = (await _login(client)).json()["access_token"] + r = await client.post( + "/v1/auth/popup/hide", + json={"popup_type": "service_info"}, + headers={"Authorization": f"Bearer {access}"}, + ) + assert r.status_code == 200 + assert r.json()["result"]["success"] is True + + r2 = await client.get("/v1/auth/popup/status", headers={"Authorization": f"Bearer {access}"}) + assert r2.json()["service_info"] is True + + # 재로그인(토큰 교체) 후에도 유지 + new_access = (await _login(client)).json()["access_token"] + r3 = await client.get("/v1/auth/popup/status", headers={"Authorization": f"Bearer {new_access}"}) + assert r3.json()["service_info"] is True + + +async def test_popup_hide_invalid_type(client, account_seed): + access = (await _login(client)).json()["access_token"] + r = await client.post( + "/v1/auth/popup/hide", + json={"popup_type": "unknown_popup"}, + headers={"Authorization": f"Bearer {access}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["result"]["success"] is False + assert body["result"]["code"] == 101 # INVALID_REQUEST_DATA + + +async def test_popup_status_without_token(client): + r = await client.get("/v1/auth/popup/status") + assert r.status_code in (401, 403) diff --git a/frontend/src/apis/auth/auth.api.ts b/frontend/src/apis/auth/auth.api.ts index 780b0cc..af16604 100644 --- a/frontend/src/apis/auth/auth.api.ts +++ b/frontend/src/apis/auth/auth.api.ts @@ -4,10 +4,13 @@ import { http } from '@/apis/http' import type { CreateAccountRequest, CreateAccountResponse, + HidePopupRequest, + HidePopupResponse, LoginRequest, LoginResponse, LogoutResponse, MeResponse, + PopupStatusResponse, } from './auth.type' export const authApi = { @@ -34,4 +37,16 @@ export const authApi = { const res = await http.post('/v1/auth/logout') return res.data }, + + /** GET /v1/auth/popup/status — 유저별 팝업 '안내 보지 않기' 저장 상태 */ + popupStatus: async (): Promise => { + const res = await http.get('/v1/auth/popup/status') + return res.data + }, + + /** POST /v1/auth/popup/hide — 해당 팝업을 다시 표시하지 않도록 영구 저장 */ + hidePopup: async (body: HidePopupRequest): Promise => { + const res = await http.post('/v1/auth/popup/hide', body) + return res.data + }, } diff --git a/frontend/src/apis/auth/auth.keys.ts b/frontend/src/apis/auth/auth.keys.ts index 3f19d6e..179f1b1 100644 --- a/frontend/src/apis/auth/auth.keys.ts +++ b/frontend/src/apis/auth/auth.keys.ts @@ -2,4 +2,5 @@ export const authKeys = { all: ['auth'] as const, me: () => [...authKeys.all, 'me'] as const, + popupStatus: () => [...authKeys.all, 'popupStatus'] as const, } diff --git a/frontend/src/apis/auth/auth.mutations.ts b/frontend/src/apis/auth/auth.mutations.ts index 1711229..67658eb 100644 --- a/frontend/src/apis/auth/auth.mutations.ts +++ b/frontend/src/apis/auth/auth.mutations.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { tokenStorage } from '@/apis/tokenStorage' import { authApi } from './auth.api' import { authKeys } from './auth.keys' -import type { CreateAccountRequest, LoginResponse } from './auth.type' +import type { CreateAccountRequest, LoginResponse, PopupType } from './auth.type' /** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */ export interface LoginParams { @@ -53,3 +53,14 @@ export function useCreateAccountMutation() { mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body), }) } + +/** 팝업 '안내 보지 않기' 영구 저장. 성공 시 상태 캐시를 무효화한다. */ +export function useHidePopupMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (popupType: PopupType) => authApi.hidePopup({ popup_type: popupType }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: authKeys.popupStatus() }) + }, + }) +} diff --git a/frontend/src/apis/auth/auth.queries.ts b/frontend/src/apis/auth/auth.queries.ts index 4167ecb..9d50340 100644 --- a/frontend/src/apis/auth/auth.queries.ts +++ b/frontend/src/apis/auth/auth.queries.ts @@ -18,3 +18,13 @@ export function useMeQuery() { select: toAuthUser, }) } + +/** 유저별 팝업 '안내 보지 않기' 저장 상태 조회 */ +export function usePopupStatusQuery() { + return useQuery({ + queryKey: authKeys.popupStatus(), + queryFn: authApi.popupStatus, + enabled: tokenStorage.hasToken(), + staleTime: 5 * 60 * 1000, // 5분 + }) +} diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts index 7b152ea..cc0e02d 100644 --- a/frontend/src/apis/auth/auth.type.ts +++ b/frontend/src/apis/auth/auth.type.ts @@ -69,6 +69,23 @@ export interface LogoutResponse { result: ApiResult } +// --- 팝업 숨김 (GET /v1/auth/popup/status, POST /v1/auth/popup/hide) -------- +/** '안내 보지 않기' 를 서버에 저장하는 팝업 종류 */ +export type PopupType = 'service_info' + +export interface PopupStatusResponse { + result: ApiResult + service_info: boolean +} + +export interface HidePopupRequest { + popup_type: PopupType +} + +export interface HidePopupResponse { + result: ApiResult +} + /** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */ export interface AuthUser { suId: string diff --git a/frontend/src/apis/auth/index.ts b/frontend/src/apis/auth/index.ts index 9d8f09d..531c31c 100644 --- a/frontend/src/apis/auth/index.ts +++ b/frontend/src/apis/auth/index.ts @@ -1,11 +1,12 @@ // 인증 API 모듈 공개 표면. export { authApi } from './auth.api' export { authKeys } from './auth.keys' -export { useMeQuery } from './auth.queries' +export { useMeQuery, usePopupStatusQuery } from './auth.queries' export { useLoginMutation, useLogoutMutation, useCreateAccountMutation, + useHidePopupMutation, type LoginParams, } from './auth.mutations' export * from './auth.type' diff --git a/frontend/src/features/chat/components/menu/Contact.tsx b/frontend/src/features/chat/components/menu/Contact.tsx index 7530870..927b00f 100644 --- a/frontend/src/features/chat/components/menu/Contact.tsx +++ b/frontend/src/features/chat/components/menu/Contact.tsx @@ -1,22 +1,28 @@ +import { useState } from 'react' import { interactive, cn } from '@/lib' +import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup' -// 헬프데스크 (TODO: 이용가이드 팝업 연동, 연락처는 추후 설정값으로 교체) +// 헬프데스크 — 이용 가이드 버튼 클릭 시 안내 팝업을 연다. export function Contact() { + const [isOpen, setIsOpen] = useState(false) + return (
헬프 데스크
-
-
-
-
+
010-0000-0000
+
o2odev@o2o.kr
+ {isOpen && setIsOpen(false)} />}
) } diff --git a/frontend/src/features/chat/components/menu/Guide.tsx b/frontend/src/features/chat/components/menu/Guide.tsx index 7a0abcf..3f79780 100644 --- a/frontend/src/features/chat/components/menu/Guide.tsx +++ b/frontend/src/features/chat/components/menu/Guide.tsx @@ -1,18 +1,26 @@ +import { useState } from 'react' import { ChevronRight } from 'lucide-react' import { interactive, cn } from '@/lib' +import { ServiceGuidePopup } from '@/features/chat/components/popup/ServiceGuidePopup' -// 유의사항 및 이용방법 (TODO: 이용가이드 팝업 연동) +// 유의사항 및 이용방법 — 클릭 시 안내 팝업을 연다. export function Guide() { + const [isOpen, setIsOpen] = useState(false) + return ( - + <> + + {isOpen && setIsOpen(false)} />} + ) } diff --git a/frontend/src/features/chat/components/popup/GuideContent.tsx b/frontend/src/features/chat/components/popup/GuideContent.tsx new file mode 100644 index 0000000..99837af --- /dev/null +++ b/frontend/src/features/chat/components/popup/GuideContent.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react' +import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' + +// 협상 유의사항 및 서비스 이용 방법 안내 — 팝업 2종(자동 안내/메뉴 가이드)이 공유하는 본문. +// VAT/배송비 문구는 채팅 init 메타(useChatInitStore)를 읽어 상품별로 동적 표시한다. +// break-keep: 한국어 단어가 중간에서 잘려 줄바꿈되지 않도록 한다. +const textStyle = 'text-[17px] font-normal leading-[150%] tracking-[-0.34px] text-neutral-90 break-keep' + +function Bullet({ children }: { children: ReactNode }) { + return ( +
+

•

+
{children}
+
+ ) +} + +export function GuideContent() { + const { item_vat_yn, item_delivery_fee_yn } = useChatInitStore() + // init 미로드/값 없음 → 보수적 기본값 (KT-NEGOWIZ 와 동일한 폴백 규칙) + const vat = item_vat_yn || 'VAT별도' + const deliveryFee = item_delivery_fee_yn || '배송비별도' + + return ( +
+
+ 협상 유의 사항 및 서비스 이용 방법 안내 +
+ +
+
+ 협상과 관련하여 다음 사항을 참고하여 견적에 참여해 주시기 바랍니다. +
+ + + 협상 개시는  + Negosium 시스템의 협상 참여 버튼을 클릭하는 순간부터 시작 + 됩니다. + + + + 부여된 협상 시간에  + + 응찰하지 않는 경우, 협상 참여의사가 없는 것으로 간주하여 재견적으로 진행 + + 될 수 있습니다. + + + + 협상에 입력되는 모든 가격은  + + {vat} 및 {deliveryFee} + +  기준이며,  + + 할인을 요청하는 경우 기존 공급가격에 할인율이 적용된 가격으로 환산 + + 되어 제시됩니다. + + + + 본 협상 결과에 대해서는 협상자와 협상대상자 간의 비밀 유지 조건으로 진행되고, 협상에서 + 얻어진 결과나 내용에 대해서는 당사자를 제외하고 제 3자에 공유할 수 없으며, 비밀 유지를 + 전제로 진행됩니다. + + + + 협상이 종결되면 특별한 사유 없이 취소 변경이 불가하니, 신중하게 협상에 참여해 주시기 + 바랍니다. + + + 안내된 사항 외 부분은 기존 견적 프로세스와 동일한 부분 유의 바랍니다. +
+
+ ) +} + +// 하단 문의 안내 박스 +export function GuideContactBox() { + return ( +
+
+ 기타 협상과정에서 궁금한 사항이나 문의하실 사항은 아래의 연락처로 상담을 부탁드립니다. +
+ {/* textStyle 의 font-normal 과 같은 요소에서 충돌하지 않도록 bold 는 별도 조합으로 준다 */} +
+ 헬프데스크 010-0000-0000, 이메일: o2odev@o2o.kr +
+
+ ) +} diff --git a/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx b/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx new file mode 100644 index 0000000..94d26c5 --- /dev/null +++ b/frontend/src/features/chat/components/popup/ServiceGuidePopup.tsx @@ -0,0 +1,29 @@ +import { X } from 'lucide-react' +import { Modal } from '@/components' +import { cn, interactive } from '@/lib' +import { GuideContent, GuideContactBox } from './GuideContent' + +// 유의사항 및 이용방법 팝업 (메뉴 버튼으로 여는 수동형). X 버튼 · 배경 클릭 · ESC 로 닫는다. +export function ServiceGuidePopup({ onClose }: { onClose: () => void }) { + return ( + +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx b/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx new file mode 100644 index 0000000..99e655b --- /dev/null +++ b/frontend/src/features/chat/components/popup/ServiceInfoPopup.tsx @@ -0,0 +1,40 @@ +import { cn, interactive } from '@/lib' +import { GuideContent, GuideContactBox } from './GuideContent' + +interface ServiceInfoPopupProps { + isOpen: boolean + /** 서버에 영구 저장 — 이후 어떤 기기에서도 다시 뜨지 않음 */ + onNeverShowAgain: () => void + /** 오늘 하루 보지 않기 (localStorage) */ + onCloseToday: () => void +} + +const buttonStyle = cn( + 'flex h-[50px] w-[168px] items-center justify-center rounded-full', + 'border border-neutral-30 bg-white title-5 text-neutral-70', + interactive, +) + +// 채팅 진입 시 자동 표시되는 서비스 안내 팝업. 버튼 2개로만 닫는다(배경 클릭/ESC 없음). +export function ServiceInfoPopup({ isOpen, onNeverShowAgain, onCloseToday }: ServiceInfoPopupProps) { + if (!isOpen) return null + + return ( +
+
+
+ + +
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/chat/containers/ChatContainer.tsx b/frontend/src/features/chat/containers/ChatContainer.tsx index c0f4ddb..b6052df 100644 --- a/frontend/src/features/chat/containers/ChatContainer.tsx +++ b/frontend/src/features/chat/containers/ChatContainer.tsx @@ -1,13 +1,16 @@ import { Loader2 } from 'lucide-react' import { ErrorPage } from '@/components' import { useChatController } from '@/features/chat/hooks/useChatController' +import { useServiceInfoPopup } from '@/features/chat/hooks/useServiceInfoPopup' import { ChatSection } from '@/features/chat/components/ChatSection' import { MenuSection } from '@/features/chat/components/menu/MenuSection' +import { ServiceInfoPopup } from '@/features/chat/components/popup/ServiceInfoPopup' // 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다. // 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도). export function ChatContainer({ sessionId }: { sessionId: string }) { const { isInitLoading, initError, refetchInit } = useChatController(sessionId) + const serviceInfo = useServiceInfoPopup() if (isInitLoading) { return ( @@ -27,6 +30,11 @@ export function ChatContainer({ sessionId }: { sessionId: string }) { return (
+
diff --git a/frontend/src/features/chat/hooks/useServiceInfoPopup.ts b/frontend/src/features/chat/hooks/useServiceInfoPopup.ts new file mode 100644 index 0000000..25e5f9a --- /dev/null +++ b/frontend/src/features/chat/hooks/useServiceInfoPopup.ts @@ -0,0 +1,33 @@ +import { useState } from 'react' +import { useHidePopupMutation, usePopupStatusQuery } from '@/apis/auth' + +// 채팅 진입 시 자동 표시되는 서비스 안내 팝업의 노출 결정. +// - '안내 보지 않기' : 서버(supplier_users.hide_service_info) 영구 저장 +// - '오늘 하루 보지 않기': localStorage 에 오늘 날짜 저장 (브라우저 단위) +const STORAGE_KEY = 'serviceInfoClosedDate' + +function isClosedToday(): boolean { + return localStorage.getItem(STORAGE_KEY) === new Date().toDateString() +} + +export function useServiceInfoPopup() { + const { data } = usePopupStatusQuery() + const { mutate: hidePopup } = useHidePopupMutation() + const [closed, setClosed] = useState(false) + + // 서버 상태 로드 전에는 열지 않는다 (숨김 유저에게 깜빡임 방지). + const isOpen = data?.result.success === true && !data.service_info && !closed && !isClosedToday() + + const neverShowAgain = () => { + setClosed(true) + hidePopup('service_info') + localStorage.removeItem(STORAGE_KEY) // 영구 숨김이 우선이므로 오늘 하루 기록은 정리 + } + + const closeToday = () => { + setClosed(true) + localStorage.setItem(STORAGE_KEY, new Date().toDateString()) + } + + return { isOpen, neverShowAgain, closeToday } +} diff --git a/postgres-init/00-init.sql b/postgres-init/00-init.sql index 1cee41b..41ea39e 100644 --- a/postgres-init/00-init.sql +++ b/postgres-init/00-init.sql @@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS supplier.supplier_users ( last_accessed_at TIMESTAMPTZ NOT NULL, -- 마지막 접속 시각 status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) role SMALLINT NOT NULL DEFAULT 1, -- 권한: 1=user(유저), 2=manager(매니저) + hide_service_info BOOLEAN NOT NULL DEFAULT FALSE, -- 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -653,9 +654,9 @@ ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC; -- 기존 DB 보정(ALTER) — 재실행 시 기존 DB 를 최신 스키마로 맞춘다 -- ============================================================ -- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은 --- 멱등 ALTER 로 여기에 함께 둔다. 신규 DB 에는 전부 no-op. --- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할). --- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용. +-- 멱등 ALTER 로 보정한다. 신규 DB 에는 전부 no-op. +-- 기준선(2026-07-07 main 스키마)까지의 보정은 이 섹션에 있고, 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용. +-- 기준선 이후의 새 스키마 변경은 위 테이블 정의를 갱신하고, 보정 ALTER 는 alters/ 아래 별도 파일로 만들어 적용한다. -- [2026-07-07] 협상 카드: script 길이 제한 해제(TEXT) + 톤·전략 분류 컬럼 ALTER TABLE card.nego_cards ALTER COLUMN script TYPE TEXT; diff --git a/postgres-init/alters/2026-07-07-supplier-users-hide-service-info.sql b/postgres-init/alters/2026-07-07-supplier-users-hide-service-info.sql new file mode 100644 index 0000000..4c636f9 --- /dev/null +++ b/postgres-init/alters/2026-07-07-supplier-users-hide-service-info.sql @@ -0,0 +1,8 @@ +-- 기존 DB 보정 — supplier_users 에 서비스 안내 팝업 "안내 보지 않기" 저장 컬럼 추가. +-- 신규 DB 는 00-init.sql 의 테이블 정의에 이미 포함되어 있어 이 파일이 필요 없다(멱등이라 실행해도 무해). +-- 적용: psql -h -p -U -f postgres-init/alters/2026-07-07-supplier-users-hide-service-info.sql + +\connect negosium_db + +ALTER TABLE supplier.supplier_users + ADD COLUMN IF NOT EXISTS hide_service_info BOOLEAN NOT NULL DEFAULT FALSE; -- 서비스 안내 팝업(협상 유의사항) "안내 보지 않기" 여부