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
9 changes: 7 additions & 2 deletions src/app/layout/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { ROUTES } from '@shared/constants';
import { useAuthStore } from '@shared/stores';
import { Navigate, Outlet } from 'react-router';

const ProtectedLayout = () => {
const accessToken = useAuthStore((state) => state.accessToken);
const { accessToken, _hasHydrated } = useAuthStore();

if (!_hasHydrated) {
return null;
}

if (!accessToken) {
return <Navigate to="/login" replace />;
return <Navigate to={ROUTES.LOGIN} replace />;
}

return <Outlet />;
Expand Down
9 changes: 7 additions & 2 deletions src/app/layout/PublicLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { ROUTES } from '@shared/constants';
import { useAuthStore } from '@shared/stores';
import { Navigate, Outlet } from 'react-router';

const PublicLayout = () => {
const accessToken = useAuthStore((state) => state.accessToken);
const { accessToken, _hasHydrated } = useAuthStore();

if (!_hasHydrated) {
return null;
}

if (accessToken) {
return <Navigate to="/" replace />;
return <Navigate to={ROUTES.MAIN} replace />;
}

return <Outlet />;
Expand Down
25 changes: 25 additions & 0 deletions src/app/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
--header-height: 92px;
--spacing-header-content: 54px;
--spacing-header-total: calc(var(--header-height) + var(--spacing-header-content));
--scrollbar-width: 0px;
}

@layer base {
Expand All @@ -109,6 +110,30 @@
color: var(--color-gray-900);
}

/* Custom Scrollbar - html only */
html::-webkit-scrollbar {
width: 8px;
}

html::-webkit-scrollbar-track {
background: transparent;
}

html::-webkit-scrollbar-thumb {
background: var(--color-gray-400);
border-radius: 4px;
}

html::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}

/* Firefox */
html {
scrollbar-width: thin;
scrollbar-color: var(--color-gray-400) transparent;
}

/* View Transitions */
@keyframes view-fade-in {
from {
Expand Down
21 changes: 21 additions & 0 deletions src/pages/chatbot/model/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const ERROR_MESSAGES = {
DEFAULT: '메시지 전송에 실패했습니다. 다시 시도해주세요.',
RATE_LIMIT: '질문 횟수를 초과했습니다. 잠시 후 다시 시도해주세요.',
API_CONNECTION: '서비스 연결에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.',
API_PARSING: '응답 처리 중 오류가 발생했습니다. 다시 시도해주세요.',
} as const;

export const INITIAL_BOT_MESSAGE = `루핏이 예상 수리비를 빠르게 계산해드릴게요. 아래 3가지만 알려주세요.
(견적은 추정치이며 실제 비용은 수리점/부품/상태에 따라 달라질 수 있어요.)

기종: 예) 아이폰 15, 갤럭시 S23

어디가 고장났는지: 예) 액정/배터리/카메라/후면/충전/침수

보험/케어 가입 여부: 예) 애플케어 O/X, 통신사 보험 O/X

가능하면 원하는 방향도 한 줄로 적어주세요: "정품 우선" / "최대한 저렴하게" / "빨리"

예시) "아이폰 15, 액정 깨짐, 애플케어 X, 최대한 저렴하게"

`;
88 changes: 88 additions & 0 deletions src/pages/chatbot/model/useChatMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useChatHistoryQuery, useSendMessageMutation } from '@shared/apis/chatbot';
import { AxiosError } from 'axios';
import { ERROR_MESSAGES, INITIAL_BOT_MESSAGE } from './constants';
import type { ChatMessage } from './types';

const getErrorMessage = (error: unknown): string => {
if (error instanceof AxiosError) {
const serverMessage = error.response?.data?.message;
if (serverMessage) {
return serverMessage;
}

const status = error.response?.status;
if (status === 429) {
return ERROR_MESSAGES.RATE_LIMIT;
}
if (status === 502) {
return ERROR_MESSAGES.API_CONNECTION;
}
}

return ERROR_MESSAGES.DEFAULT;
};

export const useChatMessages = () => {
const { data: history, isLoading: isHistoryLoading } = useChatHistoryQuery();
const sendMutation = useSendMessageMutation();

const messages: ChatMessage[] = (() => {
if (!history || history.length === 0) {
return [
{
id: 'bot-initial',
role: 'bot' as const,
content: INITIAL_BOT_MESSAGE,
status: 'done',
},
];
}

return history.map((item, index) => ({
id: `history-${index}`,
role: item.role === 'User' ? ('user' as const) : ('bot' as const),
content: item.message,
status: 'done' as const,
}));
})();

const displayMessages: ChatMessage[] = (() => {
if (!sendMutation.isPending) {
return messages;
}

const pendingUserMessage: ChatMessage = {
id: 'pending-user',
role: 'user',
content: sendMutation.variables ?? '',
status: 'done',
};

const loadingBotMessage: ChatMessage = {
id: 'pending-bot',
role: 'bot',
content: '',
status: 'loading',
};

return [...messages, pendingUserMessage, loadingBotMessage];
})();

const handleSend = (value: string) => {
const trimmed = value.trim();
if (!trimmed || sendMutation.isPending) {
return;
}

sendMutation.mutate(trimmed);
};

const errorMessage = sendMutation.isError ? getErrorMessage(sendMutation.error) : null;

return {
displayMessages,
isHistoryLoading,
errorMessage,
handleSend,
};
};
86 changes: 11 additions & 75 deletions src/pages/chatbot/ui/ChatbotPage.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,21 @@
import { Logo4 } from '@shared/assets/logo';
import { ChatInput } from '@shared/ui/ChatInput';
import { useEffect, useRef, useState } from 'react';
import { LoadingFallback } from '@shared/ui/LoadingFallback';
import { useEffect, useRef } from 'react';
import { ChatMessageList } from './ChatMessageList';
import type { ChatMessage } from '../model/types';

const INITIAL_BOT_MESSAGE = `루핏이 예상 수리비를 빠르게 계산해드릴게요. 아래 3가지만 알려주세요.
(견적은 추정치이며 실제 비용은 수리점/부품/상태에 따라 달라질 수 있어요.)

기종: 예) 아이폰 15, 갤럭시 S23

어디가 고장났는지: 예) 액정/배터리/카메라/후면/충전/침수

보험/케어 가입 여부: 예) 애플케어 O/X, 통신사 보험 O/X

가능하면 원하는 방향도 한 줄로 적어주세요: "정품 우선" / "최대한 저렴하게" / "빨리"

예시) “아이폰 15, 액정 깨짐, 애플케어 X, 최대한 저렴하게”

`;
import { useChatMessages } from '../model/useChatMessages';

const ChatbotPage = () => {
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>(() => [
{
id: 'bot-initial',
role: 'bot' as const,
content: INITIAL_BOT_MESSAGE,
status: 'done',
},
]);
const replyTimeoutRef = useRef<number | null>(null);
const { displayMessages, isHistoryLoading, errorMessage, handleSend } = useChatMessages();
const endOfMessagesRef = useRef<HTMLDivElement | null>(null);

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

useEffect(() => {
endOfMessagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages]);

const handleSend = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}

const userId = `user-${Date.now()}`;
const botId = `bot-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id: userId, role: 'user', content: trimmed, status: 'done' },
{ id: botId, role: 'bot', content: '답변을 준비 중이에요...', status: 'loading' },
]);
setMessage('');
}, [displayMessages.length]);

if (replyTimeoutRef.current) {
window.clearTimeout(replyTimeoutRef.current);
}
replyTimeoutRef.current = window.setTimeout(() => {
setMessages((prev) =>
prev.map((item) =>
item.id === botId
? {
...item,
status: 'done',
content: '입력하신 내용으로 예상 수리비를 준비할게요.\n필요하면 기종/증상 상세를 더 알려주세요!',
}
: item
)
);
}, 800);
};
if (isHistoryLoading) {
return <LoadingFallback message="대화를 불러오는 중" />;
}

return (
<div className="w-full bg-white">
Expand All @@ -88,17 +28,13 @@ const ChatbotPage = () => {
</div>
<h1 className="typo-title-2 text-black">루핏봇</h1>
</div>
<ChatMessageList messages={messages} />
<ChatMessageList messages={displayMessages} />
{errorMessage && <div className="w-full text-center text-red-500">{errorMessage}</div>}
<div ref={endOfMessagesRef} className="scroll-mb-[96px]" />
</section>
<div className="md:px-xxxl fixed inset-x-0 bottom-0 z-50 bg-white px-(--margin-l) pt-4 pb-(--margin-s) xl:px-[120px]">
<div className="mx-auto w-full max-w-full xl:max-w-[1200px]">
<ChatInput
placeholder="무슨 견적을 원하시나요?"
value={message}
onChange={setMessage}
onSend={handleSend}
/>
<ChatInput placeholder="무슨 견적을 원하시나요?" onSend={handleSend} />
</div>
</div>
</main>
Expand Down
20 changes: 9 additions & 11 deletions src/pages/login/ui/KakaoLogin.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { LoadingFallback } from '@shared/ui/LoadingFallback';
import { useKakaoLoginCallback } from '../model/useKakaoLoginCallback';

export const KakaoLogin = () => {
const { errorMessage } = useKakaoLoginCallback();

return (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-4 bg-white px-6">
{errorMessage ? (
if (errorMessage) {
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-4 bg-white px-6">
<p className="typo-body-1 text-red-500">{errorMessage}</p>
) : (
<>
<div className="border-brand-primary h-10 w-10 animate-spin rounded-full border-4 border-t-transparent" />
<p className="typo-body-1 text-gray-600">로그인 처리 중</p>
</>
)}
</div>
);
</div>
);
}

return <LoadingFallback message="로그인 처리 중" />;
};
33 changes: 30 additions & 3 deletions src/pages/main/ui/MainPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { ROUTES } from '@shared/constants';
import { useAuthStore } from '@shared/stores';
import { BannerCard } from '@shared/ui/BannerCard';
import { Carousel3D } from '@shared/ui/Carousel3D';
import { ChatbotFloatingButton } from '@shared/ui/ChatbotFloatingButton';
import { ClientOnly } from '@shared/ui/ClientOnly';
import { Modal } from '@shared/ui/Modal';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { BANNER_CARDS } from './BannerCards';
import { CAROUSEL_SLIDES } from './CarouselSlides';

const MainPage = () => {
const navigate = useNavigate();
const { accessToken } = useAuthStore();
const [showLoginModal, setShowLoginModal] = useState(false);

const handleChatbotClick = () => {
if (!accessToken) {
setShowLoginModal(true);
return;
}
navigate(ROUTES.CHATBOT, { viewTransition: true });
};

return (
<main className="md:px-xxxl flex w-full flex-col items-center gap-6 px-(--margin-l) pb-24 md:gap-10 lg:gap-[68px] lg:px-0">
<main className="md:px-xxxl flex w-full flex-col items-center gap-6 px-(--margin-l) md:gap-10 lg:gap-[68px] lg:px-0">
<section className="h-[317px] w-full max-w-[1200px]" aria-label="메인 슬로건 영역">
<ClientOnly>
<Carousel3D slides={CAROUSEL_SLIDES} />
Expand All @@ -31,9 +44,23 @@ const MainPage = () => {
</section>

<ChatbotFloatingButton
className="fixed right-4 bottom-4"
onClick={() => navigate(ROUTES.CHATBOT, { viewTransition: true })}
className="fixed right-[calc(1rem+var(--scrollbar-width))] bottom-4"
onClick={handleChatbotClick}
/>

{showLoginModal && (
<Modal
title="로그인이 필요합니다"
subtitle="로그인 페이지로 이동하시겠습니까?"
cancelText="취소"
confirmText="로그인"
onCancel={() => setShowLoginModal(false)}
onConfirm={() => {
setShowLoginModal(false);
navigate(ROUTES.LOGIN, { viewTransition: true });
}}
/>
)}
</main>
);
};
Expand Down
Loading
Loading