diff --git a/src/app/layout/ProtectedLayout.tsx b/src/app/layout/ProtectedLayout.tsx index d1a656d5..c4344d4c 100644 --- a/src/app/layout/ProtectedLayout.tsx +++ b/src/app/layout/ProtectedLayout.tsx @@ -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 ; + return ; } return ; diff --git a/src/app/layout/PublicLayout.tsx b/src/app/layout/PublicLayout.tsx index 3ce401ff..2d18070a 100644 --- a/src/app/layout/PublicLayout.tsx +++ b/src/app/layout/PublicLayout.tsx @@ -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 ; + return ; } return ; diff --git a/src/app/styles/index.css b/src/app/styles/index.css index 23e670bf..51367002 100644 --- a/src/app/styles/index.css +++ b/src/app/styles/index.css @@ -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 { @@ -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 { diff --git a/src/pages/chatbot/model/constants.ts b/src/pages/chatbot/model/constants.ts new file mode 100644 index 00000000..88ad89f1 --- /dev/null +++ b/src/pages/chatbot/model/constants.ts @@ -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, 최대한 저렴하게" + +`; diff --git a/src/pages/chatbot/model/useChatMessages.ts b/src/pages/chatbot/model/useChatMessages.ts new file mode 100644 index 00000000..7b4b887b --- /dev/null +++ b/src/pages/chatbot/model/useChatMessages.ts @@ -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, + }; +}; diff --git a/src/pages/chatbot/ui/ChatbotPage.tsx b/src/pages/chatbot/ui/ChatbotPage.tsx index 180d37a4..a21588ff 100644 --- a/src/pages/chatbot/ui/ChatbotPage.tsx +++ b/src/pages/chatbot/ui/ChatbotPage.tsx @@ -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(() => [ - { - id: 'bot-initial', - role: 'bot' as const, - content: INITIAL_BOT_MESSAGE, - status: 'done', - }, - ]); - const replyTimeoutRef = useRef(null); + const { displayMessages, isHistoryLoading, errorMessage, handleSend } = useChatMessages(); const endOfMessagesRef = useRef(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 ; + } return ( @@ -88,17 +28,13 @@ const ChatbotPage = () => { 루핏봇 - + + {errorMessage && {errorMessage}} - + diff --git a/src/pages/login/ui/KakaoLogin.tsx b/src/pages/login/ui/KakaoLogin.tsx index 335b3764..3f937ed9 100644 --- a/src/pages/login/ui/KakaoLogin.tsx +++ b/src/pages/login/ui/KakaoLogin.tsx @@ -1,18 +1,16 @@ +import { LoadingFallback } from '@shared/ui/LoadingFallback'; import { useKakaoLoginCallback } from '../model/useKakaoLoginCallback'; export const KakaoLogin = () => { const { errorMessage } = useKakaoLoginCallback(); - return ( - - {errorMessage ? ( + if (errorMessage) { + return ( + {errorMessage} - ) : ( - <> - - 로그인 처리 중 - > - )} - - ); + + ); + } + + return ; }; diff --git a/src/pages/main/ui/MainPage.tsx b/src/pages/main/ui/MainPage.tsx index 260eb5db..c37505a0 100644 --- a/src/pages/main/ui/MainPage.tsx +++ b/src/pages/main/ui/MainPage.tsx @@ -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 ( - + @@ -31,9 +44,23 @@ const MainPage = () => { navigate(ROUTES.CHATBOT, { viewTransition: true })} + className="fixed right-[calc(1rem+var(--scrollbar-width))] bottom-4" + onClick={handleChatbotClick} /> + + {showLoginModal && ( + setShowLoginModal(false)} + onConfirm={() => { + setShowLoginModal(false); + navigate(ROUTES.LOGIN, { viewTransition: true }); + }} + /> + )} ); }; diff --git a/src/shared/apis/auth/queries.ts b/src/shared/apis/auth/queries.ts index 50146b69..72521e13 100644 --- a/src/shared/apis/auth/queries.ts +++ b/src/shared/apis/auth/queries.ts @@ -1,5 +1,7 @@ +import { ROUTES } from '@shared/constants'; import { useAuthStore } from '@shared/stores'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; import { getKakaoLogin, postKakaoRegister, postLogout } from './api'; import { userKeys } from '../user/keys'; import type { KakaoLoginRequest, KakaoLoginResponse, KakaoRegisterRequest, KakaoRegisterResponse } from './types'; @@ -18,11 +20,13 @@ export const useKakaoRegisterMutation = () => { export const useLogoutMutation = () => { const queryClient = useQueryClient(); + const navigate = useNavigate(); const { clearAuth } = useAuthStore(); const cleanup = () => { clearAuth(); queryClient.removeQueries({ queryKey: userKeys.all }); + navigate(ROUTES.MAIN, { replace: true }); }; return useMutation({ diff --git a/src/shared/apis/chatbot/api.ts b/src/shared/apis/chatbot/api.ts index e69de29b..7daf97a8 100644 --- a/src/shared/apis/chatbot/api.ts +++ b/src/shared/apis/chatbot/api.ts @@ -0,0 +1,21 @@ +import { axiosInstance } from '../axiosInstance'; +import { CHATBOT_ENDPOINTS } from './endpoints'; +import type { + SendMessageRequest, + SendMessageData, + SendMessageResponseBody, + ChatHistoryData, + ChatHistoryResponseBody, +} from './types'; + +export const postSendMessage = async (request: SendMessageRequest): Promise => { + const response = await axiosInstance.post(CHATBOT_ENDPOINTS.SEND, request); + return response.data.data; +}; + +export const getChatHistory = async (userId: number): Promise => { + const response = await axiosInstance.get(CHATBOT_ENDPOINTS.HISTORY, { + params: { userId }, + }); + return response.data.data; +}; diff --git a/src/shared/apis/chatbot/endpoints.ts b/src/shared/apis/chatbot/endpoints.ts index e69de29b..bef1e0f0 100644 --- a/src/shared/apis/chatbot/endpoints.ts +++ b/src/shared/apis/chatbot/endpoints.ts @@ -0,0 +1,4 @@ +export const CHATBOT_ENDPOINTS = { + SEND: '/chatbot/send', + HISTORY: '/chatbot/history', +} as const; diff --git a/src/shared/apis/chatbot/index.ts b/src/shared/apis/chatbot/index.ts index e69de29b..f7053c67 100644 --- a/src/shared/apis/chatbot/index.ts +++ b/src/shared/apis/chatbot/index.ts @@ -0,0 +1,5 @@ +export * from './api'; +export * from './endpoints'; +export * from './keys'; +export * from './queries'; +export * from './types'; diff --git a/src/shared/apis/chatbot/keys.ts b/src/shared/apis/chatbot/keys.ts index e69de29b..9cb54a5f 100644 --- a/src/shared/apis/chatbot/keys.ts +++ b/src/shared/apis/chatbot/keys.ts @@ -0,0 +1,4 @@ +export const chatbotKeys = { + all: ['chatbot'] as const, + history: (userId: number) => [...chatbotKeys.all, 'history', userId] as const, +} as const; diff --git a/src/shared/apis/chatbot/queries.ts b/src/shared/apis/chatbot/queries.ts index e69de29b..7ef81660 100644 --- a/src/shared/apis/chatbot/queries.ts +++ b/src/shared/apis/chatbot/queries.ts @@ -0,0 +1,28 @@ +import { useAuthStore } from '@shared/stores'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getChatHistory, postSendMessage } from './api'; +import { chatbotKeys } from './keys'; + +export const useChatHistoryQuery = () => { + const { userId, accessToken, _hasHydrated } = useAuthStore(); + + return useQuery({ + queryKey: chatbotKeys.history(userId!), + queryFn: () => getChatHistory(userId!), + enabled: _hasHydrated && Boolean(userId) && Boolean(accessToken), + staleTime: 0, + refetchOnWindowFocus: false, + }); +}; + +export const useSendMessageMutation = () => { + const queryClient = useQueryClient(); + const { userId } = useAuthStore(); + + return useMutation({ + mutationFn: (message: string) => postSendMessage({ userId: userId!, message }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: chatbotKeys.history(userId!) }); + }, + }); +}; diff --git a/src/shared/apis/chatbot/types.ts b/src/shared/apis/chatbot/types.ts index e69de29b..7158ddcf 100644 --- a/src/shared/apis/chatbot/types.ts +++ b/src/shared/apis/chatbot/types.ts @@ -0,0 +1,21 @@ +import type { ApiResponse } from '../types'; + +export interface SendMessageRequest { + userId: number; + message: string; +} + +export interface SendMessageData { + reply: string; +} + +export type SendMessageResponseBody = ApiResponse; + +export interface ChatHistoryItem { + role: string; + message: string; +} + +export type ChatHistoryData = ChatHistoryItem[]; + +export type ChatHistoryResponseBody = ApiResponse; diff --git a/src/shared/constants/header.ts b/src/shared/constants/header.ts new file mode 100644 index 00000000..15b752ab --- /dev/null +++ b/src/shared/constants/header.ts @@ -0,0 +1,11 @@ +import { ROUTES } from './routes'; + +export const NAV_ITEMS = [ + { id: 'buy', label: '구매하기', path: ROUTES.BUY }, + { id: 'sell', label: '판매하기', path: ROUTES.SELL }, + { id: 'repair', label: '수리점찾기', path: ROUTES.REPAIR }, + { id: 'chat', label: '루핏톡', path: ROUTES.CHAT }, + { id: 'chatbot', label: '챗봇', path: ROUTES.CHATBOT }, +] as const; + +export const PROTECTED_PATHS: string[] = [ROUTES.SELL, ROUTES.CHATBOT, ROUTES.MYPAGE, ROUTES.CHAT]; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index c455d4bd..6a67252c 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1 +1,2 @@ +export { NAV_ITEMS, PROTECTED_PATHS } from './header'; export { ROUTES } from './routes'; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index ef5d0810..3b75bea8 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,6 +1,7 @@ export { useBodyScrollLock } from './useBodyScrollLock'; export { useClickOutside } from './useClickOutside'; export { useFocusTrap } from './useFocusTrap'; +export { useHeaderNavigation } from './useHeaderNavigation'; export { useImagePreview } from './useImagePreview'; export { useModal } from './useModal'; export { useS3ImageUpload } from './useS3ImageUpload'; diff --git a/src/shared/hooks/useBodyScrollLock.ts b/src/shared/hooks/useBodyScrollLock.ts index 8a5bc980..4ea0935a 100644 --- a/src/shared/hooks/useBodyScrollLock.ts +++ b/src/shared/hooks/useBodyScrollLock.ts @@ -6,11 +6,21 @@ export const useBodyScrollLock = (isLocked: boolean) => { return; } + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + const previousOverflow = document.body.style.overflow; + const previousPaddingRight = document.body.style.paddingRight; + document.body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + document.documentElement.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`); + } return () => { document.body.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPaddingRight; + document.documentElement.style.setProperty('--scrollbar-width', '0px'); }; }, [isLocked]); }; diff --git a/src/shared/hooks/useHeaderNavigation.ts b/src/shared/hooks/useHeaderNavigation.ts new file mode 100644 index 00000000..bb2316ab --- /dev/null +++ b/src/shared/hooks/useHeaderNavigation.ts @@ -0,0 +1,56 @@ +import { PROTECTED_PATHS, ROUTES } from '@shared/constants'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; + +type UseHeaderNavigationParams = { + isLoggedIn: boolean; + closeMobileMenu: () => void; +}; + +export const useHeaderNavigation = ({ isLoggedIn, closeMobileMenu }: UseHeaderNavigationParams) => { + const navigate = useNavigate(); + const [showLoginModal, setShowLoginModal] = useState(false); + + const handleLoginClick = () => { + navigate(ROUTES.LOGIN, { viewTransition: true }); + closeMobileMenu(); + }; + + const handleMyPageClick = () => { + if (!isLoggedIn) { + setShowLoginModal(true); + closeMobileMenu(); + return; + } + navigate(ROUTES.MYPAGE, { viewTransition: true }); + closeMobileMenu(); + }; + + const handleNavClick = (path: string) => { + if (!isLoggedIn && PROTECTED_PATHS.includes(path)) { + setShowLoginModal(true); + closeMobileMenu(); + return; + } + navigate(path, { viewTransition: true }); + closeMobileMenu(); + }; + + const handleLoginModalConfirm = () => { + setShowLoginModal(false); + navigate(ROUTES.LOGIN, { viewTransition: true }); + }; + + const handleLoginModalCancel = () => { + setShowLoginModal(false); + }; + + return { + showLoginModal, + handleLoginClick, + handleMyPageClick, + handleNavClick, + handleLoginModalConfirm, + handleLoginModalCancel, + }; +}; diff --git a/src/shared/ui/Header/Header.tsx b/src/shared/ui/Header/Header.tsx index ec80bd98..e6c80ad7 100644 --- a/src/shared/ui/Header/Header.tsx +++ b/src/shared/ui/Header/Header.tsx @@ -1,20 +1,12 @@ import { AlertDotIcon, HamburgerIcon } from '@shared/assets/icons'; -import { ROUTES } from '@shared/constants'; -import { useClickOutside, useModal } from '@shared/hooks'; +import { NAV_ITEMS } from '@shared/constants'; +import { useClickOutside, useHeaderNavigation, useModal } from '@shared/hooks'; import { Button } from '@shared/ui/Button/Button'; import { headerVariants } from '@shared/ui/Header/Header.variants'; import { UserMenu } from '@shared/ui/Header/UserMenu'; import { Logo } from '@shared/ui/Logo'; +import { Modal } from '@shared/ui/Modal'; import { type ComponentPropsWithoutRef, useRef } from 'react'; -import { useNavigate } from 'react-router'; - -const NAV_ITEMS = [ - { id: 'buy', label: '구매하기', path: ROUTES.BUY }, - { id: 'sell', label: '판매하기', path: ROUTES.SELL }, - { id: 'repair', label: '수리점찾기', path: ROUTES.REPAIR }, - { id: 'chat', label: '루핏톡', path: ROUTES.CHAT }, - { id: 'chatbot', label: '챗봇', path: ROUTES.CHATBOT }, -] as const; export type HeaderProps = Omit, 'children'> & { isLoading?: boolean; @@ -33,25 +25,18 @@ export const Header = ({ hasChatAlert = false, ...props }: HeaderProps) => { - const navigate = useNavigate(); const { isOpen: isMobileMenuOpen, toggle: toggleMobileMenu, close: closeMobileMenu } = useModal(); const mobileMenuRef = useRef(null); const styles = headerVariants(); - const handleLoginClick = () => { - navigate(ROUTES.LOGIN, { viewTransition: true }); - closeMobileMenu(); - }; - - const handleMyPageClick = () => { - navigate(ROUTES.MYPAGE, { viewTransition: true }); - closeMobileMenu(); - }; - - const handleNavClick = (path: string) => { - navigate(path, { viewTransition: true }); - closeMobileMenu(); - }; + const { + showLoginModal, + handleLoginClick, + handleMyPageClick, + handleNavClick, + handleLoginModalConfirm, + handleLoginModalCancel, + } = useHeaderNavigation({ isLoggedIn, closeMobileMenu }); useClickOutside(mobileMenuRef, isMobileMenuOpen, closeMobileMenu); @@ -134,6 +119,17 @@ export const Header = ({ )} + + {showLoginModal && ( + + )} ); }; diff --git a/src/shared/ui/Header/Header.variants.ts b/src/shared/ui/Header/Header.variants.ts index 83749727..6de6a754 100644 --- a/src/shared/ui/Header/Header.variants.ts +++ b/src/shared/ui/Header/Header.variants.ts @@ -19,8 +19,10 @@ export const headerVariants = tv({ 'justify-between', 'items-center', 'bg-white', - 'px-10', - 'xl:px-[120px]', + 'pl-10', + 'pr-[calc(40px+var(--scrollbar-width))]', + 'xl:pl-[120px]', + 'xl:pr-[calc(120px+var(--scrollbar-width))]', ], logo: ['w-[192px]', 'h-[36px]', 'shrink-0'], diff --git a/src/shared/ui/LoadingFallback/LoadingFallback.tsx b/src/shared/ui/LoadingFallback/LoadingFallback.tsx new file mode 100644 index 00000000..212aa19b --- /dev/null +++ b/src/shared/ui/LoadingFallback/LoadingFallback.tsx @@ -0,0 +1,12 @@ +export interface LoadingFallbackProps { + message?: string; +} + +export const LoadingFallback = ({ message = '로딩 중...' }: LoadingFallbackProps) => { + return ( + + + {message} + + ); +}; diff --git a/src/shared/ui/LoadingFallback/index.ts b/src/shared/ui/LoadingFallback/index.ts new file mode 100644 index 00000000..1116993e --- /dev/null +++ b/src/shared/ui/LoadingFallback/index.ts @@ -0,0 +1,2 @@ +export { LoadingFallback } from './LoadingFallback'; +export type { LoadingFallbackProps } from './LoadingFallback';
{errorMessage}
로그인 처리 중
{message}