Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,6 +95,16 @@ const routes = [
} satisfies ScreenRouteHandle,
path: "/notices/:noticeId",
},
{
element: <ChatEntryScreen />,
// Figma 챗봇 진입 화면에는 Bottom Nav가 없다(뒤로가기로 홈에 복귀). 흰 배경 위에
// 그라데이션이 얹히는 구조라 다른 흰 배경 화면들과 같은 normal을 쓴다.
handle: {
background: "normal",
hasBottomNav: false,
} satisfies ScreenRouteHandle,
path: "/chat",
},
// 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다
{ element: <ComingSoonScreen />, path: "*" },
],
Expand Down
12 changes: 12 additions & 0 deletions src/assets/icons/chat/bot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions src/assets/icons/chat/gradient-blob.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/assets/icons/chat/sparkle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 39 additions & 27 deletions src/components/ui/ScreenLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -69,42 +70,53 @@ function ScreenLayout({
const [sheetPortalEl, setSheetPortalEl] = useState<HTMLDivElement | null>(
null,
);
const [backgroundPortalEl, setBackgroundPortalEl] =
useState<HTMLDivElement | null>(null);
const location = useLocation();
const navigate = useNavigate();
const bottomNavValue = getBottomNavValueFromPath(location.pathname);

return (
<ScreenHeaderContext.Provider value={setHeader}>
<ScreenSheetPortalContext.Provider value={sheetPortalEl}>
<div
className={`relative flex h-dvh w-full flex-col overflow-hidden pt-safe-top sm:w-[480px] sm:pt-0 sm:shadow-[0_0_20px_rgba(0,0,0,0.05)] ${BACKGROUND_CLASS_NAMES[background]}`}
>
<div className="shrink-0">{header}</div>
{/* 스크롤 처리는 각 화면이 스스로 결정한다(예: 상단 토글/필터는 고정하고 목록만 스크롤).
overflow-y-auto가 동작하려면 자식 높이가 명확해야 해서, 화면마다 h-full을 직접
챙기지 않아도 되도록 여기서 기본으로 보장한다. */}
<div className="flex-1 overflow-hidden">
<div className="flex h-full flex-col">
<Outlet />
<ScreenBackgroundPortalContext.Provider value={backgroundPortalEl}>
<div
className={`relative flex h-dvh w-full flex-col overflow-hidden sm:w-[480px] sm:shadow-[0_0_20px_rgba(0,0,0,0.05)] ${BACKGROUND_CLASS_NAMES[background]}`}
>
{/* 화면 전용 배경 포털 대상 — 프레임 안에서 가장 먼저(맨 아래) 그려져서, 투명한
헤더(예: 챗봇 진입 화면)까지 자연스럽게 비쳐 보인다. 콘텐츠가 없는 화면에서는
빈 채로 있어 다른 화면에 영향이 없다. */}
<div
className="pointer-events-none absolute inset-0"
ref={setBackgroundPortalEl}
/>
<div className="shrink-0">{header}</div>
{/* 스크롤 처리는 각 화면이 스스로 결정한다(예: 상단 토글/필터는 고정하고 목록만 스크롤).
overflow-y-auto가 동작하려면 자식 높이가 명확해야 해서, 화면마다 h-full을 직접
챙기지 않아도 되도록 여기서 기본으로 보장한다. */}
<div className="flex-1 overflow-hidden">
<div className="flex h-full flex-col">
<Outlet />
</div>
</div>
{hasBottomNav && (
<div className="shrink-0">
<BottomNav
onValueChange={(value) => navigate(BOTTOM_NAV_PATHS[value])}
value={bottomNavValue}
/>
</div>
)}
{/* BottomSheet 포털 대상 — 헤더/본문/Bottom Nav보다 위(z-50)에 겹쳐서, 화면 하나가
열어도 화면 프레임 전체를 딤 처리할 수 있다. 시트가 닫혀있을 때는 빈 오버레이가
클릭을 가로채지 않도록 pointer-events-none — BottomSheet가 열릴 때 자기 자신에만
pointer-events-auto를 되돌려준다. */}
<div
className="pointer-events-none absolute inset-0 z-50"
ref={setSheetPortalEl}
/>
</div>
{hasBottomNav && (
<div className="shrink-0">
<BottomNav
onValueChange={(value) => navigate(BOTTOM_NAV_PATHS[value])}
value={bottomNavValue}
/>
</div>
)}
{/* BottomSheet 포털 대상 — 헤더/본문/Bottom Nav보다 위(z-50)에 겹쳐서, 화면 하나가
열어도 화면 프레임 전체를 딤 처리할 수 있다. 시트가 닫혀있을 때는 빈 오버레이가
클릭을 가로채지 않도록 pointer-events-none — BottomSheet가 열릴 때 자기 자신에만
pointer-events-auto를 되돌려준다. */}
<div
className="pointer-events-none absolute inset-0 z-50"
ref={setSheetPortalEl}
/>
</div>
</ScreenBackgroundPortalContext.Provider>
</ScreenSheetPortalContext.Provider>
</ScreenHeaderContext.Provider>
);
Expand Down
8 changes: 8 additions & 0 deletions src/components/ui/screenBackgroundPortalContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createContext } from "react";

// 화면 전용 배경 레이어(예: 챗봇 진입 화면의 그라데이션)가 포털로 렌더링될 위치. ScreenLayout의
// 375×812 프레임 최상단에, 헤더보다도 먼저(DOM 순서상 맨 아래) 깔리는 빈 div를 가리켜서
// 헤더(투명 배경)·본문 뒤로 자연스럽게 비쳐 보이면서도 브라우저 전체가 아니라 그 프레임
// 안에서만 그려진다. sheet 포털(ScreenSheetPortalContext)과 반대로 항상 맨 아래 레이어다.
export const ScreenBackgroundPortalContext =
createContext<HTMLDivElement | null>(null);
10 changes: 10 additions & 0 deletions src/components/ui/useScreenBackgroundPortal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useContext } from "react";

import { ScreenBackgroundPortalContext } from "@/components/ui/screenBackgroundPortalContext";

// 화면 전체(헤더 포함) 뒤에 깔릴 배경 레이어가 그려질 DOM 노드를 가져온다. 마운트 첫 렌더에는
// ref가 아직 안 붙어서 null일 수 있으므로(프로그래밍 오류가 아니라 정상적인 과도 상태),
// 호출부에서 null을 직접 처리한다.
export function useScreenBackgroundPortal() {
return useContext(ScreenBackgroundPortalContext);
}
182 changes: 182 additions & 0 deletions src/features/chat/ChatEntryScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
const [isBotLoading, setIsBotLoading] = useState(false);
const nextMessageIdRef = useRef(0);
const botTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messageListRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const chatStarted = messages.length > 0;

useEffect(() => {
return () => {
if (botTimerRef.current) {
clearTimeout(botTimerRef.current);
}
};
}, []);

// 메시지가 추가되거나 응답 대기 점이 나타날 때마다 최신 대화가 보이도록 맨 아래로 스크롤한다.
// biome-ignore lint/correctness/useExhaustiveDependencies: 값을 직접 참조하진 않지만 변경 시점을 감지해 스크롤을 트리거하기 위해 의도적으로 의존성에 둔다
useEffect(() => {
const messageList = messageListRef.current;
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [messages, isBotLoading]);

// 봇 응답 대기 중에는 입력창이 disabled되며 포커스가 풀린다 — 응답이 오면(대기 종료 시점)
// 바로 이어서 타이핑할 수 있도록 입력창에 포커스를 되돌린다.
const wasBotLoadingRef = useRef(false);
useEffect(() => {
if (wasBotLoadingRef.current && !isBotLoading) {
inputRef.current?.focus();
}
wasBotLoadingRef.current = isBotLoading;
}, [isBotLoading]);

useScreenHeader(
<ScreenHeader
leading={
<TopNavigationButton
aria-label="뒤로가기"
onClick={() => navigate(-1)}
variant="icon"
>
<IconChevronLeft />
</TopNavigationButton>
}
title="Stream AI"
variant="normal"
/>,
);

function sendMessage(text: string) {
const trimmed = text.trim();
// 봇 응답을 기다리는 동안 새 메시지를 보내면 대기 중이던 타이머가 취소돼 그 메시지의
// 응답이 영영 안 온다(코드리뷰 지적) — 응답이 올 때까지는 전송 자체를 막는다.
if (!trimmed || isBotLoading) {
return;
}
nextMessageIdRef.current += 1;
setMessages((prev) => [
...prev,
{ id: nextMessageIdRef.current, sender: "user", text: trimmed },
]);
setInputValue("");

setIsBotLoading(true);
botTimerRef.current = setTimeout(() => {
nextMessageIdRef.current += 1;
setMessages((prev) => [
...prev,
{ id: nextMessageIdRef.current, sender: "bot", text: "" },
]);
setIsBotLoading(false);
}, BOT_RESPONSE_DELAY_MS);
}

return (
<div className="relative flex h-full flex-col">
<ChatGradientBackground showBlob={!chatStarted} />
<div className="relative flex-1 overflow-hidden">
<div
className={`absolute inset-0 flex flex-col gap-8 overflow-y-auto px-5 pt-8 transition-opacity duration-300 ${
chatStarted ? "pointer-events-none opacity-0" : "opacity-100"
}`}
>
<div className="flex w-[170px] flex-col gap-2">
<Typography color="semantic.label.alternative" variant="label1">
어떤 점이 궁금하세요?
</Typography>
<div className="flex flex-col gap-1">
<Typography
color="semantic.label.strong"
variant="heading1"
weight="bold"
>
<span className="bg-gradient-to-r from-primary to-[#6f66ff] bg-clip-text text-transparent">
Stream AI
</span>
가
</Typography>
<Typography
color="semantic.label.strong"
variant="heading1"
weight="bold"
>
바로 답해 드릴게요
</Typography>
</div>
</div>
<div className="flex flex-col items-start gap-2">
{CHAT_SUGGESTED_QUESTIONS.map((question) => (
<SuggestedQuestion
key={question.id}
onClick={() => sendMessage(question.text)}
question={question.text}
/>
))}
</div>
</div>
<div
className={`absolute inset-0 flex flex-col gap-3 overflow-y-auto pt-4 pb-4 transition-opacity duration-300 ${
chatStarted ? "opacity-100" : "pointer-events-none opacity-0"
}`}
ref={messageListRef}
>
{messages.map((message) =>
message.sender === "user" ? (
<ChatMessageBubble key={message.id} message={message.text} />
) : (
<ChatBotMessageBubble key={message.id} />
),
)}
{isBotLoading && <ChatLoadingDots />}
</div>
</div>
<div className="relative px-5 pb-5">
<ChatInput
disabled={isBotLoading}
onChange={setInputValue}
onSubmit={() => sendMessage(inputValue)}
ref={inputRef}
value={inputValue}
/>
</div>
</div>
);
}

export default ChatEntryScreen;
32 changes: 32 additions & 0 deletions src/features/chat/components/ChatBotMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-start gap-3 px-5">
<div className="flex size-11 items-center justify-center rounded-[22px] bg-chatbot-avatar">
<img alt="" className="h-[19.6px] w-[23.4px]" src={botIcon} />
</div>
<div className="w-[272px] max-w-[272px] rounded-tl-[4px] rounded-tr-2xl rounded-br-2xl rounded-bl-2xl bg-background-alternative px-4 py-2.5">
<Typography color="semantic.label.normal" variant="body2-reading">
학생회비는{" "}
<strong className="font-bold">
마이 &gt; 학생 서비스 &gt; 회비 납부 내역
</strong>
에서 확인하실 수 있어요!
<br />
<br />
혹시 메뉴를 찾기 어려우시면 제가 안내해 드릴게요!
</Typography>
</div>
</div>
);
}

export default ChatBotMessageBubble;
Loading