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
59 changes: 59 additions & 0 deletions mocks/domain/advice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { http, HttpResponse, delay } from 'msw';
import { AdviceChatRequest, AdviceChatResponse } from '@/model/advice/types';

// 조언 채팅 요청 핸들러
export const requestAdviceChat = http.post('/advice/chat', async ({ request }) => {
// 2초 딜레이
await delay(2000);

const body = (await request.json()) as AdviceChatRequest;

// 응답 스타일에 따른 메시지 예시
const adviceMessages: Record<string, string> = {
BASIC: '목표를 달성하기 위해서는 꾸준한 노력이 필요해요. 오늘 하루도 화이팅!',
WARM: '정말 잘하고 계세요! 😊 당신의 노력이 분명히 좋은 결과로 이어질 거예요. 힘내세요!',
FACTUAL: `${body.week}주차 목표를 분석한 결과, 계획된 작업을 단계적으로 진행하는 것이 효율적입니다.`,
STRATEGIC:
'목표 달성을 위해 우선순위를 정하고 작은 성취를 쌓아가는 전략을 추천드려요. 구체적인 실행 계획을 세워보세요.',
};

const grorongResponse = adviceMessages[body.adviceStyle] || '오늘도 목표를 향해 한 걸음 나아가세요!';

const response: AdviceChatResponse = {
data: {
remainingCount: 5, // 남은 조언 횟수
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock returns remainingCount: 5, but the UI copy and badge default imply a 1-day max of 3 (e.g., “1일 3개 입력만 가능”, maxAdviceCount = 3). Aligning the mock remainingCount with the UI limit will avoid confusing Storybook/MSW runs.

Suggested change
remainingCount: 5, // 남은 조언 횟수
remainingCount: 3, // 남은 조언 횟수 (UI max와 일치)

Copilot uses AI. Check for mistakes.
isGoalOnboardingCompleted: body.isGoalOnboardingCompleted ?? true,
conversations: [
{
userMessage: body.userMessage,
grorongResponse,
timestamp: new Date().toISOString(),
},
],
},
};

return HttpResponse.json(response, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
});

// CORS preflight 요청 처리
export const optionsHandler = http.options('*', () => {
return new HttpResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
});

// 모든 Advice 핸들러들을 배열로 export
export const adviceHandlers = [optionsHandler, requestAdviceChat];
3 changes: 2 additions & 1 deletion mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { http, HttpResponse } from 'msw';
//import { todoHandlers } from './domain/todo';
import { adviceHandlers } from './domain/advice';
import { getContribution } from '../src/composite/home/contributionGraph/api';

// 테스트용 더미 데이터
Expand Down Expand Up @@ -73,4 +74,4 @@ const getJobRoles = http.get('/resource/jobroles', () => {
});

// 이 배열에 api 함수들을 넣어 작동
export const handlers = [getUsers, login, reissue, getJobRoles, getContribution];
export const handlers = [getUsers, login, reissue, getJobRoles, getContribution, ...adviceHandlers];
142 changes: 53 additions & 89 deletions src/composite/advice/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,27 @@

import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { AdviceMutation, AdviceQuery } from '@/model/advice/queries';
import { AdviceHeader } from '@/feature/advice/components/AdviceHeader';
import { GoalQuery } from '@/model/goal/queries';
import { getMsUntilEndOfDay } from '@/shared/lib/utils';
import { useState } from 'react';
import { Goal } from '@/shared/type/goal';
import { AdviceChat, AdviceStyle } from '@/model/advice/types';
import { AdviceChat } from '@/model/advice/types';
import { AdviceHeader } from '@/feature/advice/components/AdviceHeader';
import { AdviceCountBadge } from '@/feature/advice/components/AdviceCountBadge';
import { AdviceSendButton } from '@/feature/advice/components/AdviceSendButton';
import { InputField } from '@/shared/components/input/InputField';
import { AdviceStyleSelectSheet } from '@/feature/advice/components/AdviceStyleSelectSheet';
import { useBottomSheet } from '@/shared/components/feedBack/BottomSheet';
import { useToast } from '@/shared/components/feedBack/toast';
import { ADVICE_STYLE_SELECT_ITEMS } from './constants';
import { AdviceQueryKeys } from '@/model/advice/queryKeys';
import { AdviceChatHistory } from '@/feature/advice/components/AdviceChatHistory';
import { HasNoProgressGoalPage } from '@/app/(home)/advice/HasNoProgressGoalPage';
import { AdviceArrivalPopupProvider } from '@/feature/advice/hooks/useSubscribeAdviceArrival';
import { Z_INDEX } from '@/shared/lib/z-index';
import { AdviceArrivalPopupWrapper } from '@/feature/advice/hooks/useSubscribeAdviceArrival';
import { AdviceSubmitForm } from '@/feature/advice/components/AdviceSubmitFormElements';
import { useToast } from '@/shared/components/feedBack/toast';
import { AdviceQueryKeys } from '@/model/advice/queryKeys';
import { AdviceFormContext, AdviceStyleSelectContext } from '@/feature/advice/components/AdviceFormContext';
import { useBottomSheet } from '@/shared/components/feedBack/BottomSheet';

export default function AdviceChatClient() {
const msUntilEndOfDay = getMsUntilEndOfDay();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { isOpen, showSheet, closeSheet } = useBottomSheet();

const { data: progressGoals = [] } = useSuspenseQuery(
GoalQuery.getProgressGoals({
staleTime: msUntilEndOfDay,
Expand All @@ -36,32 +36,9 @@ export default function AdviceChatClient() {
})
);

if (!progressGoals || !progressGoals.length) return <HasNoProgressGoalPage />;

return (
<AdviceArrivalPopupProvider adviceChat={adviceChat}>
<AdviceChatClientContent progressGoals={progressGoals} adviceChat={adviceChat} />
</AdviceArrivalPopupProvider>
);
}

type AdviceChatClientContentProps = {
progressGoals: Goal[];
adviceChat: AdviceChat;
};

function AdviceChatClientContent({ progressGoals, adviceChat }: AdviceChatClientContentProps) {
const [selectedGoal, setSelectedGoal] = useState<Goal | null>(progressGoals[0] ?? null);
const [selectedAdviceStyle, setSelectedAdviceStyle] = useState<AdviceStyle>('BASIC');
const [userMessage, setUserMessage] = useState('');

const queryClient = useQueryClient();
const { showToast } = useToast();
const { isOpen, showSheet, closeSheet } = useBottomSheet();
const { mutateAsync: requestAdvice, isPending: isSendingRequest } = useMutation(
AdviceMutation.requestAdvice({
onSuccess: () => {
setUserMessage('');
queryClient.invalidateQueries({ queryKey: AdviceQueryKeys.chat() });
},
onError: () => {
Expand All @@ -70,71 +47,58 @@ function AdviceChatClientContent({ progressGoals, adviceChat }: AdviceChatClient
})
);

const handleRequestAdvice = () => {
if (!selectedGoal) return;
const adviceChatRequest = {
week: 1,
goalId: selectedGoal.id,
userMessage: userMessage,
adviceStyle: selectedAdviceStyle,
};
requestAdvice(adviceChatRequest);
};
if (!progressGoals || !progressGoals.length) return <HasNoProgressGoalPage />;

return (
<AdviceArrivalPopupWrapper adviceChat={adviceChat}>
{/** 조언 폼 제출에 필요한 상태 관리, 남은 횟수, isPending */}
<AdviceFormContext.Provider
value={{
remainingCount: adviceChat?.remainingCount || 0,
isSendingRequest: isSendingRequest,
requestAdvice: requestAdvice,
}}
>
{/** 조언 스타일 선택 시트 관련 상태 관리 */}
<AdviceStyleSelectContext.Provider
value={{
isSheetOpen: isOpen,
openSheet: showSheet,
closeSheet: closeSheet,
}}
>
<AdviceChatClientContent progressGoals={progressGoals} adviceChat={adviceChat} />
</AdviceStyleSelectContext.Provider>
</AdviceFormContext.Provider>
</AdviceArrivalPopupWrapper>
);
}

type AdviceChatClientContentProps = {
progressGoals: Goal[];
adviceChat: AdviceChat;
};

function AdviceChatClientContent({ progressGoals = [], adviceChat }: AdviceChatClientContentProps) {
return (
<div className="flex flex-col flex-1 bg-[url('/advice/advice-chat-bg.png')] bg-cover bg-center">
<AdviceHeader progressGoals={progressGoals} selectedGoal={selectedGoal} setSelectedGoal={setSelectedGoal} />
<AdviceHeader />
<main className="flex flex-1 flex-col relative text-sm tracking-wide">
<AdviceChatHistory adviceChat={adviceChat} isSendingRequest={isSendingRequest} />
<AdviceChatHistory adviceChat={adviceChat} />
</main>
<div
className={`bg-elevated-normal flex flex-col gap-y-2 rounded-t-2xl px-5 w-full sticky bottom-0 ${Z_INDEX.SHEET}`}
>
<nav className="w-full flex justify-between items-center pt-5">
<button onClick={showSheet} className="flex items-center gap-x-2 body-1-normal text-text-strong">
{ADVICE_STYLE_SELECT_ITEMS[selectedAdviceStyle].title}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<AdviceSubmitForm.Root goalId={progressGoals[0]?.id}>
<AdviceSubmitForm.StyleSelectTrigger>
<AdviceCountBadge adviceCount={adviceChat.remainingCount} />
</nav>
</AdviceSubmitForm.StyleSelectTrigger>
<section className="pb-5 w-full flex items-center gap-x-2">
<div className="flex-1">
<InputField
version="underline"
name="advice-message"
value={userMessage}
onChange={e => setUserMessage(e.target.value)}
placeholder="지금 목표에서 뭐부터 하면 좋을까?"
/>
<AdviceSubmitForm.Input />
</div>
<AdviceSendButton
type="button"
onKeyDown={e => e.key === 'Enter' && handleRequestAdvice()}
onClick={handleRequestAdvice}
disabled={userMessage.length === 0 || adviceChat.remainingCount === 0 || !adviceChat}
/>
<AdviceSubmitForm.Button />
</section>

<AdviceStyleSelectSheet
isOpen={isOpen}
showSheet={showSheet}
closeSheet={closeSheet}
selectedAdviceStyle={selectedAdviceStyle}
setSelectedAdviceStyle={setSelectedAdviceStyle}
/>
</div>
<AdviceSubmitForm.StyleSelectSheet />
</AdviceSubmitForm.Root>
</div>
);
}
8 changes: 4 additions & 4 deletions src/composite/home/homeBanner/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,26 @@ export type BannerData = Record<GrorongMood, BannerContent[]>;
*/
export const BANNER_MESSAGES: BannerData = {
HAPPY: [
{ message: '성실한 너를 위해\n꽃다발을 준비했어..' },
{ message: '안녕!\n내 하트를 받아줄래?' },
{ message: '너 덕분에\n그로롱 성실도 +100' },
{ message: '안녕!\n내 하트를 받아줄래?' },
{ message: '내 친구왔다!\n모두 소리질러~!' },
{ message: '너가 열심히 하니까\n내 맘이 평화로워' },
{ message: '성실한 너를 위해\n꽃다발을 준비했어..' },
{ message: '매일매일이 생일 같아\n너무 고마워!' },
],
NORMAL: [
{ message: '지금처럼 와주면\n그로롱 몸짱될 예정!' },
{ message: '나도 너를 본받아\n열심히 공부하고 있어' },
{ message: '지금처럼 와주면\n그로롱 몸짱될 예정!' },
{ message: '안녕\n너를 봐서 설레!' },
{ message: '너가 와서 신나는\n이 맘을 춤으로 표현할게' },
{ message: '요즘 너의 목표 현황\n완전 꿀잼이야' },
{ message: '너가 온 날은\n나에게 봄날같아' },
],
SAD: [
{ message: '너가 없는 시간은\n365일 겨울같아' },
{ message: '빨리 와서 구해줘\n돌이 되기 직전이야' },
{ message: '500만년만에 와서\n그로롱 해골 됨' },
{ message: '기다렸는데,,\n외로웠는데,,' },
{ message: '빨리 와서 구해줘\n돌이 되기 직전이야' },
{ message: '안 오는거야?\n나 흑화했어' },
{ message: '네가 없는 거리에는\n내가 할일이 없어서' },
],
Expand Down
10 changes: 10 additions & 0 deletions src/feature/advice/AdviceSubmitFormSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';

const AdviceFormSchema = z.object({
week: z.number(),
goalId: z.string().min(1, '목표 ID는 필수값입니다'),
userMessage: z.string().min(1, '조언 요청 메시지는 최소 1자 이상이어야 합니다'),
adviceStyle: z.enum(['BASIC', 'WARM', 'FACTUAL', 'STRATEGIC']).default('BASIC'),
});

export { AdviceFormSchema };
20 changes: 13 additions & 7 deletions src/feature/advice/components/AdviceChatHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { useEffect, useRef } from 'react';
import { AdviceChat } from '@/model/advice/types';
import { AdviceChatMessage } from './AdviceChatMessage';
import { useAdviceChatMessages } from '../hooks/useAdviceChatMessages';
import { useAdviceFormContext } from '../hooks/useAdviceFormContext';

type AdviceChatRenderProps = {
adviceChat: AdviceChat | null;
isSendingRequest?: boolean;
};

export const AdviceChatHistory = ({ adviceChat = null, isSendingRequest = false }: AdviceChatRenderProps) => {
export const AdviceChatHistory = ({ adviceChat = null }: { adviceChat: AdviceChat | null }) => {
const { isSendingRequest } = useAdviceFormContext();
const { displayMessages, backgroundType, shouldShowOnboarding } = useAdviceChatMessages(adviceChat, isSendingRequest);

const scrollRef = useRef<HTMLElement>(null);
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useRef<HTMLElement>(null) is a type error under strict: true because null isn’t assignable to HTMLElement. Use a nullable ref type (e.g., useRef<HTMLElement | null>(null)) or a non-null assertion if you can guarantee initialization.

Suggested change
const scrollRef = useRef<HTMLElement>(null);
const scrollRef = useRef<HTMLElement | null>(null);

Copilot uses AI. Check for mistakes.

useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}
}, [displayMessages]);

// Early return: NoGoal 케이스
if (!adviceChat) {
return <AdviceChatMessage.NoGoal />;
Expand All @@ -23,7 +29,7 @@ export const AdviceChatHistory = ({ adviceChat = null, isSendingRequest = false
// 일반 채팅 히스토리 렌더링
return (
<>
<section className="px-5 pb-[138px] absolute inset-0 overflow-y-auto z-10 space-y-4">
<section ref={scrollRef} className="px-5 pb-34.5 absolute inset-0 overflow-y-auto z-10 space-y-4">
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pb-34.5 is not a default Tailwind spacing step (and there’s no custom spacing config in the repo), so this class will likely be ignored and the chat list may overlap the sticky form. Consider using an arbitrary value (e.g., pb-[138px]) or an existing spacing token.

Suggested change
<section ref={scrollRef} className="px-5 pb-34.5 absolute inset-0 overflow-y-auto z-10 space-y-4">
<section ref={scrollRef} className="px-5 pb-[138px] absolute inset-0 overflow-y-auto z-10 space-y-4">

Copilot uses AI. Check for mistakes.
{displayMessages.map((message, index) => {
// 오늘 대화 횟수 소진 안내메시지
if (message.isSystemMessage) {
Expand Down
20 changes: 20 additions & 0 deletions src/feature/advice/components/AdviceFormContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext } from 'react';
import { UseMutateAsyncFunction } from '@tanstack/react-query';
import { AdviceChat, AdviceChatRequest } from '@/model/advice/types';

/** 폼 제출 함수, 제출 상태 컨텍스트 */
type TAdviceFormContext = {
requestAdvice: UseMutateAsyncFunction<AdviceChat, Error, AdviceChatRequest, unknown>;
isSendingRequest: boolean;
remainingCount: number;
};
export const AdviceFormContext = createContext<TAdviceFormContext | null>(null);

/** 조언 스타일 선택 시트 열림 상태, 열기/닫기 함수 컨텍스트 */
type TAdviceStyleSelectContext = {
isSheetOpen: boolean;
openSheet: () => void;
closeSheet: () => void;
};

export const AdviceStyleSelectContext = createContext<TAdviceStyleSelectContext | null>(null);
Loading
Loading