From 3e8c7080ea87399ec33110e498eff1d417fcd28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:46:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=B0=EA=B2=BD=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=8F=AC=ED=84=B8=EC=9D=84=20?= =?UTF-8?q?ScreenLayout=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/ScreenLayout.tsx | 66 +++++++++++-------- .../ui/screenBackgroundPortalContext.ts | 8 +++ .../ui/useScreenBackgroundPortal.ts | 10 +++ 3 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 src/components/ui/screenBackgroundPortalContext.ts create mode 100644 src/components/ui/useScreenBackgroundPortal.ts diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 0453b1e..ca4baac 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import BottomNav, { type BottomNavValue } from "@/components/ui/BottomNav"; +import { ScreenBackgroundPortalContext } from "@/components/ui/screenBackgroundPortalContext"; import { ScreenHeaderContext } from "@/components/ui/screenHeaderContext"; import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalContext"; @@ -63,6 +64,8 @@ function ScreenLayout({ const [sheetPortalEl, setSheetPortalEl] = useState( null, ); + const [backgroundPortalEl, setBackgroundPortalEl] = + useState(null); const location = useLocation(); const navigate = useNavigate(); const bottomNavValue = getBottomNavValueFromPath(location.pathname); @@ -70,35 +73,44 @@ function ScreenLayout({ return ( -
-
{header}
- {/* 스크롤 처리는 각 화면이 스스로 결정한다(예: 상단 토글/필터는 고정하고 목록만 스크롤). - overflow-y-auto가 동작하려면 자식 높이가 명확해야 해서, 화면마다 h-full을 직접 - 챙기지 않아도 되도록 여기서 기본으로 보장한다. */} -
-
- + +
+ {/* 화면 전용 배경 포털 대상 — 프레임 안에서 가장 먼저(맨 아래) 그려져서, 투명한 + 헤더(예: 챗봇 진입 화면)까지 자연스럽게 비쳐 보인다. 콘텐츠가 없는 화면에서는 + 빈 채로 있어 다른 화면에 영향이 없다. */} +
+
{header}
+ {/* 스크롤 처리는 각 화면이 스스로 결정한다(예: 상단 토글/필터는 고정하고 목록만 스크롤). + overflow-y-auto가 동작하려면 자식 높이가 명확해야 해서, 화면마다 h-full을 직접 + 챙기지 않아도 되도록 여기서 기본으로 보장한다. */} +
+
+ +
+ {hasBottomNav && ( +
+ navigate(BOTTOM_NAV_PATHS[value])} + value={bottomNavValue} + /> +
+ )} + {/* BottomSheet 포털 대상 — 헤더/본문/Bottom Nav보다 위(z-50)에 겹쳐서, 화면 하나가 + 열어도 화면 프레임 전체를 딤 처리할 수 있다. 시트가 닫혀있을 때는 빈 오버레이가 + 클릭을 가로채지 않도록 pointer-events-none — BottomSheet가 열릴 때 자기 자신에만 + pointer-events-auto를 되돌려준다. */} +
- {hasBottomNav && ( -
- navigate(BOTTOM_NAV_PATHS[value])} - value={bottomNavValue} - /> -
- )} - {/* BottomSheet 포털 대상 — 헤더/본문/Bottom Nav보다 위(z-50)에 겹쳐서, 화면 하나가 - 열어도 화면 프레임 전체를 딤 처리할 수 있다. 시트가 닫혀있을 때는 빈 오버레이가 - 클릭을 가로채지 않도록 pointer-events-none — BottomSheet가 열릴 때 자기 자신에만 - pointer-events-auto를 되돌려준다. */} -
-
+ ); diff --git a/src/components/ui/screenBackgroundPortalContext.ts b/src/components/ui/screenBackgroundPortalContext.ts new file mode 100644 index 0000000..ad1c582 --- /dev/null +++ b/src/components/ui/screenBackgroundPortalContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; + +// 화면 전용 배경 레이어(예: 챗봇 진입 화면의 그라데이션)가 포털로 렌더링될 위치. ScreenLayout의 +// 375×812 프레임 최상단에, 헤더보다도 먼저(DOM 순서상 맨 아래) 깔리는 빈 div를 가리켜서 +// 헤더(투명 배경)·본문 뒤로 자연스럽게 비쳐 보이면서도 브라우저 전체가 아니라 그 프레임 +// 안에서만 그려진다. sheet 포털(ScreenSheetPortalContext)과 반대로 항상 맨 아래 레이어다. +export const ScreenBackgroundPortalContext = + createContext(null); diff --git a/src/components/ui/useScreenBackgroundPortal.ts b/src/components/ui/useScreenBackgroundPortal.ts new file mode 100644 index 0000000..da98aed --- /dev/null +++ b/src/components/ui/useScreenBackgroundPortal.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; + +import { ScreenBackgroundPortalContext } from "@/components/ui/screenBackgroundPortalContext"; + +// 화면 전체(헤더 포함) 뒤에 깔릴 배경 레이어가 그려질 DOM 노드를 가져온다. 마운트 첫 렌더에는 +// ref가 아직 안 붙어서 null일 수 있으므로(프로그래밍 오류가 아니라 정상적인 과도 상태), +// 호출부에서 null을 직접 처리한다. +export function useScreenBackgroundPortal() { + return useContext(ScreenBackgroundPortalContext); +} From 6806b5245282af36016cc0b55a11126e46308e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:47:02 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85/=EB=8C=80=ED=99=94=20=ED=99=94=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 11 ++ src/assets/icons/chat/bot.svg | 12 ++ src/assets/icons/chat/gradient-blob.svg | 22 +++ src/assets/icons/chat/sparkle.svg | 11 ++ src/features/chat/ChatEntryScreen.tsx | 159 ++++++++++++++++++ .../chat/components/ChatBotMessageBubble.tsx | 32 ++++ .../components/ChatGradientBackground.tsx | 57 +++++++ src/features/chat/components/ChatInput.tsx | 46 +++++ .../chat/components/ChatLoadingDots.tsx | 23 +++ .../chat/components/ChatMessageBubble.tsx | 25 +++ .../chat/components/SuggestedQuestion.tsx | 33 ++++ .../chat/constants/chatSuggestedQuestions.ts | 10 ++ src/index.css | 65 +++++++ 13 files changed, 506 insertions(+) create mode 100644 src/assets/icons/chat/bot.svg create mode 100644 src/assets/icons/chat/gradient-blob.svg create mode 100644 src/assets/icons/chat/sparkle.svg create mode 100644 src/features/chat/ChatEntryScreen.tsx create mode 100644 src/features/chat/components/ChatBotMessageBubble.tsx create mode 100644 src/features/chat/components/ChatGradientBackground.tsx create mode 100644 src/features/chat/components/ChatInput.tsx create mode 100644 src/features/chat/components/ChatLoadingDots.tsx create mode 100644 src/features/chat/components/ChatMessageBubble.tsx create mode 100644 src/features/chat/components/SuggestedQuestion.tsx create mode 100644 src/features/chat/constants/chatSuggestedQuestions.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index 6eb52c5..910453d 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,6 +6,7 @@ import ScreenLayoutRoute, { type ScreenRouteHandle, } from "@/app/ScreenLayoutRoute"; import BililgeListScreen from "@/features/bililge/BililgeListScreen"; +import ChatEntryScreen from "@/features/chat/ChatEntryScreen"; import EventsApplicationClosedScreen from "@/features/events/EventsApplicationClosedScreen"; import EventsApplicationCompleteScreen from "@/features/events/EventsApplicationCompleteScreen"; import EventsApplicationScreen from "@/features/events/EventsApplicationScreen"; @@ -84,6 +85,16 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/notices/:noticeId", }, + { + element: , + // Figma 챗봇 진입 화면에는 Bottom Nav가 없다(뒤로가기로 홈에 복귀). 흰 배경 위에 + // 그라데이션이 얹히는 구조라 다른 흰 배경 화면들과 같은 normal을 쓴다. + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/chat", + }, // 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다 { element: , path: "*" }, ], diff --git a/src/assets/icons/chat/bot.svg b/src/assets/icons/chat/bot.svg new file mode 100644 index 0000000..259d658 --- /dev/null +++ b/src/assets/icons/chat/bot.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/chat/gradient-blob.svg b/src/assets/icons/chat/gradient-blob.svg new file mode 100644 index 0000000..b7514bf --- /dev/null +++ b/src/assets/icons/chat/gradient-blob.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/chat/sparkle.svg b/src/assets/icons/chat/sparkle.svg new file mode 100644 index 0000000..557dae5 --- /dev/null +++ b/src/assets/icons/chat/sparkle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/features/chat/ChatEntryScreen.tsx b/src/features/chat/ChatEntryScreen.tsx new file mode 100644 index 0000000..1485b01 --- /dev/null +++ b/src/features/chat/ChatEntryScreen.tsx @@ -0,0 +1,159 @@ +import { TopNavigationButton, Typography } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import ChatBotMessageBubble from "@/features/chat/components/ChatBotMessageBubble"; +import ChatGradientBackground from "@/features/chat/components/ChatGradientBackground"; +import ChatInput from "@/features/chat/components/ChatInput"; +import ChatLoadingDots from "@/features/chat/components/ChatLoadingDots"; +import ChatMessageBubble from "@/features/chat/components/ChatMessageBubble"; +import SuggestedQuestion from "@/features/chat/components/SuggestedQuestion"; +import { CHAT_SUGGESTED_QUESTIONS } from "@/features/chat/constants/chatSuggestedQuestions"; + +interface ChatMessage { + id: number; + sender: "user" | "bot"; + text: string; +} + +// 실제 AI 응답(백엔드 연동)은 이번 범위 밖이라, 메시지를 보내면 Figma의 "로딩모션" 상태 +// (응답 대기 점 3개)를 잠깐 보여준 뒤 ChatBotMessageBubble의 목업 답변으로 넘어간다 — +// 사용자가 실제로 입력한 내용과 무관하게 항상 같은 응답이다. +const BOT_RESPONSE_DELAY_MS = 1200; + +// Figma: 챗봇 진입 화면(nodeId 1887:91534)과 챗봇 대화창(1770:63314) — 메시지를 하나라도 +// 보내면 진입 화면의 웰컴 문구·그라데이션·추천 질문이 페이드아웃되고 대화창(말풍선 + 응답 +// 대기 점)이 페이드인된다. 두 레이어를 같은 자리에 absolute로 겹쳐서 opacity만 트랜지션하므로 +// 레이아웃이 튀지 않는다. +function ChatEntryScreen() { + const navigate = useNavigate(); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [isBotLoading, setIsBotLoading] = useState(false); + const nextMessageIdRef = useRef(0); + const botTimerRef = useRef | null>(null); + const chatStarted = messages.length > 0; + + useEffect(() => { + return () => { + if (botTimerRef.current) { + clearTimeout(botTimerRef.current); + } + }; + }, []); + + useScreenHeader( + navigate(-1)} + variant="icon" + > + + + } + title="Stream AI" + variant="normal" + />, + ); + + function sendMessage(text: string) { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + nextMessageIdRef.current += 1; + setMessages((prev) => [ + ...prev, + { id: nextMessageIdRef.current, sender: "user", text: trimmed }, + ]); + setInputValue(""); + + setIsBotLoading(true); + if (botTimerRef.current) { + clearTimeout(botTimerRef.current); + } + botTimerRef.current = setTimeout(() => { + nextMessageIdRef.current += 1; + setMessages((prev) => [ + ...prev, + { id: nextMessageIdRef.current, sender: "bot", text: "" }, + ]); + setIsBotLoading(false); + }, BOT_RESPONSE_DELAY_MS); + } + + return ( +
+ +
+
+
+ + 어떤 점이 궁금하세요? + +
+ + + Stream AI + + 가 + + + 바로 답해 드릴게요 + +
+
+
+ {CHAT_SUGGESTED_QUESTIONS.map((question) => ( + sendMessage(question.text)} + question={question.text} + /> + ))} +
+
+
+ {messages.map((message) => + message.sender === "user" ? ( + + ) : ( + + ), + )} + {isBotLoading && } +
+
+
+ sendMessage(inputValue)} + value={inputValue} + /> +
+
+ ); +} + +export default ChatEntryScreen; diff --git a/src/features/chat/components/ChatBotMessageBubble.tsx b/src/features/chat/components/ChatBotMessageBubble.tsx new file mode 100644 index 0000000..a96b8ef --- /dev/null +++ b/src/features/chat/components/ChatBotMessageBubble.tsx @@ -0,0 +1,32 @@ +import { Typography } from "@wanteddev/wds"; + +import botIcon from "@/assets/icons/chat/bot.svg"; + +// Figma: 챗봇 대화창의 Bot Response(nodeId 1770:63307) — 아바타(44px, Atomic/Blue/65 +// #4F95FF·홈 FAB의 primary #0066FF와는 다른 대화 중 전용 색) + 말풍선(왼쪽 정렬, 좌상단만 +// 4px로 꺾임). 실제 AI 응답(백엔드 연동)은 이번 범위 밖이라, Figma가 이 화면의 mock +// 콘텐츠로 이미 써둔 답변 텍스트를 그대로 목업으로 보여준다 — 사용자가 실제로 입력한 +// 질문과 무관하게 항상 같은 응답이다. +function ChatBotMessageBubble() { + return ( +
+
+ +
+
+ + 학생회비는{" "} + + 마이 > 학생 서비스 > 회비 납부 내역 + + 에서 확인하실 수 있어요! +
+
+ 혹시 메뉴를 찾기 어려우시면 제가 안내해 드릴게요! +
+
+
+ ); +} + +export default ChatBotMessageBubble; diff --git a/src/features/chat/components/ChatGradientBackground.tsx b/src/features/chat/components/ChatGradientBackground.tsx new file mode 100644 index 0000000..93dacc0 --- /dev/null +++ b/src/features/chat/components/ChatGradientBackground.tsx @@ -0,0 +1,57 @@ +import { createPortal } from "react-dom"; +import gradientBlob from "@/assets/icons/chat/gradient-blob.svg"; +import { useScreenBackgroundPortal } from "@/components/ui/useScreenBackgroundPortal"; + +// Figma: 챗봇 진입 화면 배경의 Background Blur — 실제 원 2개(파랑 #589BFE·보라 #896CFF, +// blur(100px), opacity 0.5)를 합친 SVG다. "화면 1"(nodeId 1887:91534)과 "화면 2" +// (1887:91558)가 이 이미지의 위치·크기만 다른 상태로 존재하는데, Figma에 실제 Smart Animate는 +// 연결돼 있지 않다(get_motion_context 결과 없음) — 두 상태를 애니메이션의 시작/끝 프레임으로 +// 보고 CSS @keyframes(chat-gradient-drift, index.css)로 그 사이를 오가게 만들었다. +// Figma 원본은 이 이미지를 늘리지 않고 원본 크기(736×675) 그대로 두고, 애니메이션하는 박스 +// (chat-gradient-blob) 중앙에 맞춰서만 보여준다 — inset 음수 크롭이 그 방식이다. 박스 크기가 +// 바뀌어도 이미지 자체는 그대로고 "어디까지 보이는지"만 바뀌는 셈이라, 여기서도 이미지를 +// 늘리는 대신 flex 중앙 정렬로 같은 결과를 낸다(늘리면 블러 가장자리가 뭉개진다). +// +// left/top 좌표는 Figma에서 화면 루트(375×812) 기준이라 — 헤더(56px)까지 포함해 화면 전체 +// 뒤에 깔려야 한다. ChatEntryScreen의 본문 영역(헤더 아래)에만 두면 좌표가 56px 밀리고, +// 헤더 위로는 아예 안 비쳐서 그라데이션이 실제보다 아래로 처지고 옅어 보인다 — 그래서 화면 +// 콘텐츠의 일부로 두지 않고 ScreenLayout의 배경 포털(프레임 전체, 헤더보다 먼저 그려짐)에 +// 그린다. +// +// Figma 화면 루트(1887:91534)의 배경은 흰색(Background/Normal/Normal)이고 그 위에 이 블러가 +// 얹힌다 — router.tsx에서 /chat 라우트에 handle: { background: "normal" }을 지정해서 +// ScreenLayout 루트 자체가 흰 배경을 깔아주므로, 여기서는 블러만 그린다. +// +// 대화가 시작되면(메시지를 하나라도 보내면) Figma의 "챗봇 대화창"(1770:63314) 화면은 이 +// 블러 없이 흰 배경만 쓴다 — showBlob=false로 블러만 페이드아웃한다. +interface ChatGradientBackgroundProps { + showBlob?: boolean; +} + +function ChatGradientBackground({ + showBlob = true, +}: ChatGradientBackgroundProps) { + const portalEl = useScreenBackgroundPortal(); + + if (!portalEl) { + return null; + } + + return createPortal( + , + portalEl, + ); +} + +export default ChatGradientBackground; diff --git a/src/features/chat/components/ChatInput.tsx b/src/features/chat/components/ChatInput.tsx new file mode 100644 index 0000000..bda2ae2 --- /dev/null +++ b/src/features/chat/components/ChatInput.tsx @@ -0,0 +1,46 @@ +import { IconArrowUp } from "@wanteddev/wds-icon"; + +interface ChatInputProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; +} + +// Figma: 챗봇 입력창 — state=Default(nodeId 1628:175742, 빈 입력: placeholder 회색 텍스트 + +// 비활성 회색(#e1e2e4) 전송 버튼)와 state=Active(nodeId 1770:61488, 입력 있음: 검정 텍스트 + +// 파란(primary) 전송 버튼)를 입력값 유무로 토글한다. WDS `TextField`는 box-shadow + +// backdrop-blur(32px)가 기본이라 Figma의 flat 배경(그림자 없음)과 달라 재사용하지 않는다. +// 텍스트 스타일(15px/0.0096em)은 WDS Typography의 body2 값을 그대로 따랐다 — Typography +// 컴포넌트 자체는 자기 태그를 렌더링해서 네이티브 input에는 못 씌운다. +function ChatInput({ value, onChange, onSubmit }: ChatInputProps) { + const isActive = value.trim().length > 0; + + return ( +
{ + event.preventDefault(); + onSubmit(); + }} + > + onChange(event.target.value)} + placeholder="Stream AI에게 무엇이든 물어보세요" + value={value} + /> + +
+ ); +} + +export default ChatInput; diff --git a/src/features/chat/components/ChatLoadingDots.tsx b/src/features/chat/components/ChatLoadingDots.tsx new file mode 100644 index 0000000..47f5ae8 --- /dev/null +++ b/src/features/chat/components/ChatLoadingDots.tsx @@ -0,0 +1,23 @@ +const DOT_DELAYS_MS = [0, 200, 400]; + +// Figma: 챗봇 대화창의 응답 대기 점 3개(nodeId 1770:63320) — CSS 키프레임은 index.css의 +// chat-loading-dot 참고(원본 애니메이션 트랙을 점 크기에 맞게 축소한 이유 포함). +function ChatLoadingDots() { + return ( +
+ {DOT_DELAYS_MS.map((delay) => ( + + ))} +
+ ); +} + +export default ChatLoadingDots; diff --git a/src/features/chat/components/ChatMessageBubble.tsx b/src/features/chat/components/ChatMessageBubble.tsx new file mode 100644 index 0000000..1827c5b --- /dev/null +++ b/src/features/chat/components/ChatMessageBubble.tsx @@ -0,0 +1,25 @@ +import { Typography } from "@wanteddev/wds"; + +interface ChatMessageBubbleProps { + message: string; +} + +// Figma: 챗봇 대화창의 User Message 말풍선(nodeId 1628:176004) — 우측 정렬, 우상단만 4px로 +// 꺾여서 "내가 보낸" 말풍선임을 표시한다. 텍스트 색은 Figma가 실제로 Background/Normal/Normal +// (흰색) 변수를 그대로 재사용해서 바인딩해뒀다 — static.white 대신 그 변수를 그대로 따른다. +function ChatMessageBubble({ message }: ChatMessageBubbleProps) { + return ( +
+
+ + {message} + +
+
+ ); +} + +export default ChatMessageBubble; diff --git a/src/features/chat/components/SuggestedQuestion.tsx b/src/features/chat/components/SuggestedQuestion.tsx new file mode 100644 index 0000000..5e16c2e --- /dev/null +++ b/src/features/chat/components/SuggestedQuestion.tsx @@ -0,0 +1,33 @@ +import { Typography } from "@wanteddev/wds"; + +import sparkleIcon from "@/assets/icons/chat/sparkle.svg"; + +interface SuggestedQuestionProps { + question: string; + onClick?: () => void; +} + +// Figma: 챗봇 진입 화면의 Suggested Question(nodeId 139:2612) — 클릭하면 그 질문을 그대로 +// 챗봇에 보내는 동작이라 div가 아니라 button으로 구현한다. +// 아이콘은 보라→파랑 그라데이션 fill(#8567FF → #6075FF)이라 WDS `IconSparkleFill`(currentColor +// 단색)과 다르다 — Stream 전용 에셋으로 다운로드해 그대로 쓴다. +function SuggestedQuestion({ question, onClick }: SuggestedQuestionProps) { + return ( + + ); +} + +export default SuggestedQuestion; diff --git a/src/features/chat/constants/chatSuggestedQuestions.ts b/src/features/chat/constants/chatSuggestedQuestions.ts new file mode 100644 index 0000000..11f0c9a --- /dev/null +++ b/src/features/chat/constants/chatSuggestedQuestions.ts @@ -0,0 +1,10 @@ +// Figma: 챗봇 진입 화면의 Suggested Questions(nodeId 1887:91551) — 5개 항목 중 4개가 같은 +// mock 텍스트를 그대로 반복하고 있다(디자인 목업 상 placeholder). 실제 추천 질문 콘텐츠는 +// API 연동 전까지 이 배열의 값을 그대로 쓴다. 텍스트가 중복돼 렌더링 key로 못 써서 id를 같이 둔다. +export const CHAT_SUGGESTED_QUESTIONS = [ + { id: "student-fee-check-1", text: "학생회비는 어떻게 확인하나요?" }, + { id: "slrangje", text: "슬랑제가 뭔가요?" }, + { id: "student-fee-check-2", text: "학생회비는 어떻게 확인하나요?" }, + { id: "student-fee-check-3", text: "학생회비는 어떻게 확인하나요?" }, + { id: "student-fee-check-4", text: "학생회비는 어떻게 확인하나요?" }, +]; diff --git a/src/index.css b/src/index.css index 81be834..404d3f6 100644 --- a/src/index.css +++ b/src/index.css @@ -21,6 +21,13 @@ --color-line-solid-neutral: var(--semantic-line-solid-neutral); --color-line-normal-neutral: var(--semantic-line-normal-neutral); + --color-line-solid-alternative: var(--semantic-line-solid-alternative); + + /* 챗봇 대화창의 "내가 보낸" 말풍선 배경(Figma "Label/Neutral", rgba(46,47,51,0.88)) */ + --color-label-neutral: var(--semantic-label-neutral); + /* 챗봇 대화창 아바타 배경(Figma "Atomic/Blue/65") — 홈 FAB의 primary(#0066FF)와는 다른, + 대화 중 상태 전용 색이다 */ + --color-chatbot-avatar: var(--atomic-blue-65); /* 제출 실패 토스트의 느낌표 아이콘(Figma Atomic/Red/60)과 그 뒤에 까는 흰 바탕(Figma Static/White) */ --color-toast-negative: var(--atomic-red-60); @@ -54,3 +61,61 @@ display: none; } } + +/* 챗봇 진입 화면 배경 블러 — Figma에 실제 애니메이션(Smart Animate)은 없지만, "화면 1" + (nodeId 1887:91534)과 "화면 2"(1887:91558)가 이 블러 위치·크기만 다른 상태라 애니메이션의 + 시작/끝 키프레임으로 보고 그 사이를 오가게 만들었다. 회전각(36.29deg)은 두 상태 동일해서 + 고정, left/top/width/height만 애니메이션한다. */ +@keyframes chat-gradient-drift { + from { + left: 64px; + top: -142px; + width: 433.592px; + height: 420.529px; + } + to { + left: -105px; + top: -158.16px; + width: 493.592px; + height: 476.281px; + } +} + +@utility chat-gradient-blob { + animation: chat-gradient-drift 5s ease-in-out infinite alternate; + + @media (prefers-reduced-motion: reduce) { + animation: none; + left: 64px; + top: -142px; + width: 433.592px; + height: 420.529px; + } +} + +/* 챗봇 대화창의 응답 대기 점 3개(nodeId 1770:63320) — get_motion_context가 준 원본 트랙은 + 점이 최대 -30px까지 튀어오르는데, 이 점이 들어있는 프레임 자체가 세로 7.2px라 그대로 쓰면 + 지나치게 크게 튄다. 점 3개가 약 200ms 간격으로 순서대로 튀는 파형만 그대로 가져오고, 튀는 + 높이는 점 크기에 맞춰 4px로 줄였다. */ +@keyframes chat-loading-dot { + 0%, + 40%, + 100% { + transform: translateY(0) scale(1); + opacity: 0.2; + } + + 20% { + transform: translateY(-4px) scale(1.2); + opacity: 0.8; + } +} + +@utility chat-loading-dot { + animation: chat-loading-dot 2s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 0.6; + } +} From ba40456ccd9fcce90b5edb8b965d973675cebd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:47:06 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EC=B1=97=EB=B4=87=20=EC=A7=84=EC=9E=85=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/HomeScreen.tsx | 33 +++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/features/home/HomeScreen.tsx b/src/features/home/HomeScreen.tsx index 3f44520..62680c7 100644 --- a/src/features/home/HomeScreen.tsx +++ b/src/features/home/HomeScreen.tsx @@ -1,12 +1,17 @@ import { TopNavigationButton } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Link } from "react-router-dom"; - +import { Link, useNavigate } from "react-router-dom"; +import chatbotIcon from "@/assets/icons/chat/bot.svg"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; // 홈 화면 콘텐츠는 아직 없어서, 라우팅이 실제로 동작하는지 확인할 placeholder만 둔다. +// 챗봇 FAB(Figma nodeId 2443:178457 등, 홈 화면 variant마다 우하단에 고정)만 먼저 구현한다 — +// 우측 20px·바텀 내비 위 24px 고정 위치는 Figma에서 홈 variant별로 다른 화면 높이에서도 +// Bottom Nav 상단과의 간격이 항상 24px로 일정한 걸 좌표로 확인해서 얻은 값이다. function HomeScreen() { + const navigate = useNavigate(); + useScreenHeader( -

- 홈 화면은 아직 준비 중이에요 -

- - 빌릴게 화면 보기 - +
+
+

+ 홈 화면은 아직 준비 중이에요 +

+ + 빌릴게 화면 보기 + +
+
); } From 01af1fe12716115603da5c2c1e90bf3d0be0edae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:38:58 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=B4=87=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=A4=91=20=EC=A4=91=EB=B3=B5=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=B0=8F=20reduced-motion=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=A7=80=EC=85=98=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/chat/ChatEntryScreen.tsx | 8 ++++---- .../chat/components/ChatGradientBackground.tsx | 2 +- src/features/chat/components/ChatInput.tsx | 10 +++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/features/chat/ChatEntryScreen.tsx b/src/features/chat/ChatEntryScreen.tsx index 1485b01..691a735 100644 --- a/src/features/chat/ChatEntryScreen.tsx +++ b/src/features/chat/ChatEntryScreen.tsx @@ -63,7 +63,9 @@ function ChatEntryScreen() { function sendMessage(text: string) { const trimmed = text.trim(); - if (!trimmed) { + // 봇 응답을 기다리는 동안 새 메시지를 보내면 대기 중이던 타이머가 취소돼 그 메시지의 + // 응답이 영영 안 온다(코드리뷰 지적) — 응답이 올 때까지는 전송 자체를 막는다. + if (!trimmed || isBotLoading) { return; } nextMessageIdRef.current += 1; @@ -74,9 +76,6 @@ function ChatEntryScreen() { setInputValue(""); setIsBotLoading(true); - if (botTimerRef.current) { - clearTimeout(botTimerRef.current); - } botTimerRef.current = setTimeout(() => { nextMessageIdRef.current += 1; setMessages((prev) => [ @@ -147,6 +146,7 @@ function ChatEntryScreen() {
sendMessage(inputValue)} value={inputValue} diff --git a/src/features/chat/components/ChatGradientBackground.tsx b/src/features/chat/components/ChatGradientBackground.tsx index 93dacc0..c49e654 100644 --- a/src/features/chat/components/ChatGradientBackground.tsx +++ b/src/features/chat/components/ChatGradientBackground.tsx @@ -43,7 +43,7 @@ function ChatGradientBackground({ className="pointer-events-none absolute inset-0 overflow-hidden" >
diff --git a/src/features/chat/components/ChatInput.tsx b/src/features/chat/components/ChatInput.tsx index bda2ae2..b376a7e 100644 --- a/src/features/chat/components/ChatInput.tsx +++ b/src/features/chat/components/ChatInput.tsx @@ -4,6 +4,7 @@ interface ChatInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; + disabled?: boolean; } // Figma: 챗봇 입력창 — state=Default(nodeId 1628:175742, 빈 입력: placeholder 회색 텍스트 + @@ -12,7 +13,9 @@ interface ChatInputProps { // backdrop-blur(32px)가 기본이라 Figma의 flat 배경(그림자 없음)과 달라 재사용하지 않는다. // 텍스트 스타일(15px/0.0096em)은 WDS Typography의 body2 값을 그대로 따랐다 — Typography // 컴포넌트 자체는 자기 태그를 렌더링해서 네이티브 input에는 못 씌운다. -function ChatInput({ value, onChange, onSubmit }: ChatInputProps) { +// disabled(코드리뷰 지적 반영): 봇 응답을 기다리는 동안 또 보내면 앞선 응답 타이머가 취소돼 +// 그 메시지에 대한 응답이 영영 안 온다 — 응답이 올 때까지 입력 자체를 막는다. +function ChatInput({ value, onChange, onSubmit, disabled }: ChatInputProps) { const isActive = value.trim().length > 0; return ( @@ -25,6 +28,7 @@ function ChatInput({ value, onChange, onSubmit }: ChatInputProps) { > onChange(event.target.value)} placeholder="Stream AI에게 무엇이든 물어보세요" value={value} @@ -32,9 +36,9 @@ function ChatInput({ value, onChange, onSubmit }: ChatInputProps) {
diff --git a/src/features/chat/components/ChatInput.tsx b/src/features/chat/components/ChatInput.tsx index b376a7e..f962ea3 100644 --- a/src/features/chat/components/ChatInput.tsx +++ b/src/features/chat/components/ChatInput.tsx @@ -1,4 +1,5 @@ import { IconArrowUp } from "@wanteddev/wds-icon"; +import { forwardRef } from "react"; interface ChatInputProps { value: string; @@ -15,36 +16,39 @@ interface ChatInputProps { // 컴포넌트 자체는 자기 태그를 렌더링해서 네이티브 input에는 못 씌운다. // disabled(코드리뷰 지적 반영): 봇 응답을 기다리는 동안 또 보내면 앞선 응답 타이머가 취소돼 // 그 메시지에 대한 응답이 영영 안 온다 — 응답이 올 때까지 입력 자체를 막는다. -function ChatInput({ value, onChange, onSubmit, disabled }: ChatInputProps) { - const isActive = value.trim().length > 0; +const ChatInput = forwardRef( + function ChatInput({ value, onChange, onSubmit, disabled }, ref) { + const isActive = value.trim().length > 0; - return ( -
{ - event.preventDefault(); - onSubmit(); - }} - > - onChange(event.target.value)} - placeholder="Stream AI에게 무엇이든 물어보세요" - value={value} - /> - -
- ); -} + onChange(event.target.value)} + placeholder="Stream AI에게 무엇이든 물어보세요" + ref={ref} + value={value} + /> + + + ); + }, +); export default ChatInput; From 505bb5708b6c92e5b0b79d5b2b9072525cc0305a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:49:02 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=B1=97=EB=B4=87=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=EC=99=80=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=B0=BD=20=EC=82=AC=EC=9D=B4=20=EC=97=AC?= =?UTF-8?q?=EB=B0=B1=20=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/features/chat/ChatEntryScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/chat/ChatEntryScreen.tsx b/src/features/chat/ChatEntryScreen.tsx index 8b87e30..07794de 100644 --- a/src/features/chat/ChatEntryScreen.tsx +++ b/src/features/chat/ChatEntryScreen.tsx @@ -151,7 +151,7 @@ function ChatEntryScreen() {