Skip to content

Commit 9a27abc

Browse files
authored
Merge pull request #222 from DDD-Community/epic/goal-onboard
Feat: 목표생성 온보딩 페이지 적용
2 parents 474e126 + 67d2176 commit 9a27abc

File tree

25 files changed

+546
-5
lines changed

25 files changed

+546
-5
lines changed
1.6 MB
Loading
179 KB
Loading
262 KB
Loading
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { useMutation, useQueryClient } from '@tanstack/react-query';
6+
import { useFormContext } from 'react-hook-form';
7+
import { FunnelHeader, FunnelHeaderProvider } from '@/shared/components/layout/FunnelHeader';
8+
import { FunnelNextButton } from '@/shared/components/layout/FunnelNextButton';
9+
import { SwipeActionButton } from '@/shared/components/input/SwipeActionButton';
10+
import { WelcomeStep, GoalNameStep, DateStep, CompleteStep } from '@/composite/goal-onboard';
11+
import { ROUTES } from '@/shared/constants/routes';
12+
import { useFetchUserName } from '@/shared/hooks';
13+
import { GoalMutation } from '@/model/goal/hooks';
14+
import { GoalQueryKeys } from '@/model/goal/queryKeys';
15+
import { userApi, UserQueryKeys } from '@/model/user';
16+
import { useToast } from '@/shared/components/feedBack/toast';
17+
import { CreateGoalFormElement } from '@/feature/goal';
18+
import { GoalFormData } from '@/shared/type/form';
19+
import { CreateGoalResponseData } from '@/feature/goal/confimGoal/api';
20+
21+
const TOTAL_STEPS = 4;
22+
23+
function GoalOnboardContent() {
24+
const router = useRouter();
25+
const queryClient = useQueryClient();
26+
const { showToast } = useToast();
27+
const { fullUserName } = useFetchUserName();
28+
29+
const [currentStep, setCurrentStep] = useState(1);
30+
const [createdGoalData, setCreatedGoalData] = useState<CreateGoalResponseData | null>(null);
31+
32+
const { watch } = useFormContext<GoalFormData>();
33+
const goalName = watch('name');
34+
const startDate = watch('durationDate.startDate');
35+
const endDate = watch('durationDate.endDate');
36+
37+
const { mutate: createGoal, isPending } = useMutation(
38+
GoalMutation.createGoal({
39+
onSuccess: data => {
40+
queryClient.invalidateQueries({ queryKey: GoalQueryKeys.progress() });
41+
setCreatedGoalData(data);
42+
setCurrentStep(4); // 완료 스텝으로 이동
43+
},
44+
onError: () => {
45+
showToast('목표 생성에 실패했습니다.', 'error');
46+
},
47+
})
48+
);
49+
50+
const { mutate: updateOnboardStatus } = useMutation({
51+
mutationFn: userApi.putOnboardStatus,
52+
onSuccess: () => {
53+
queryClient.invalidateQueries({ queryKey: UserQueryKeys.onboardStatus() });
54+
setCurrentStep(2);
55+
},
56+
onError: () => {
57+
showToast('온보딩 등록에 실패했습니다.', 'error');
58+
},
59+
});
60+
61+
const validateStep = (): boolean => {
62+
switch (currentStep) {
63+
case 2:
64+
return !!goalName?.trim();
65+
case 3:
66+
return !!startDate && !!endDate;
67+
default:
68+
return true;
69+
}
70+
};
71+
72+
const handleNext = () => {
73+
if (!validateStep()) return;
74+
75+
if (currentStep === 1) {
76+
// 온보딩 등록 API 호출
77+
updateOnboardStatus();
78+
return;
79+
}
80+
81+
if (currentStep === 3) {
82+
// 목표 생성 API 호출
83+
createGoal({
84+
category: '',
85+
name: goalName,
86+
duration: 0,
87+
durationDate: {
88+
startDate,
89+
endDate,
90+
},
91+
toBe: '',
92+
plans: [],
93+
});
94+
return;
95+
}
96+
97+
if (currentStep < TOTAL_STEPS) {
98+
setCurrentStep(currentStep + 1);
99+
}
100+
};
101+
102+
const handleBack = () => {
103+
if (currentStep > 1) {
104+
setCurrentStep(currentStep - 1);
105+
} else {
106+
router.push(ROUTES.HOME);
107+
}
108+
};
109+
110+
const handleComplete = () => {
111+
router.push(ROUTES.HOME);
112+
showToast('목표 생성이 완료되었습니다.', 'success');
113+
};
114+
115+
const getButtonText = (): string => {
116+
switch (currentStep) {
117+
case 1:
118+
return '목표 설정하기';
119+
case 3:
120+
return isPending ? '생성 중...' : '완료';
121+
case 4:
122+
return '목표 시작하기';
123+
default:
124+
return '다음';
125+
}
126+
};
127+
128+
const isButtonDisabled = (): boolean => {
129+
if (isPending) return true;
130+
131+
switch (currentStep) {
132+
case 2:
133+
return !goalName?.trim();
134+
case 3:
135+
return !startDate || !endDate;
136+
default:
137+
return false;
138+
}
139+
};
140+
141+
const renderStep = () => {
142+
switch (currentStep) {
143+
case 1:
144+
return <WelcomeStep userName={fullUserName} />;
145+
case 2:
146+
return <GoalNameStep />;
147+
case 3:
148+
return <DateStep />;
149+
case 4:
150+
return (
151+
<CompleteStep goalName={goalName} startDate={startDate} endDate={endDate} planet={createdGoalData?.planet} />
152+
);
153+
default:
154+
return null;
155+
}
156+
};
157+
158+
return (
159+
<FunnelHeaderProvider>
160+
<main className="flex flex-1 flex-col h-screen overflow-hidden bg-normal">
161+
{currentStep > 1 && currentStep < 4 && (
162+
<FunnelHeader
163+
currentStep={currentStep - 1}
164+
totalSteps={TOTAL_STEPS - 2}
165+
onBack={handleBack}
166+
title="목표 추가"
167+
/>
168+
)}
169+
170+
<div className="flex flex-1 flex-col overflow-hidden">{renderStep()}</div>
171+
172+
{currentStep === 4 ? (
173+
<div className="fixed bottom-0 left-0 right-0 p-5 max-w-md mx-auto">
174+
<SwipeActionButton text="목표 시작하기" onSwipeComplete={handleComplete} />
175+
</div>
176+
) : (
177+
<FunnelNextButton text={getButtonText()} onClick={handleNext} disabled={isButtonDisabled()} />
178+
)}
179+
</main>
180+
</FunnelHeaderProvider>
181+
);
182+
}
183+
184+
export default function GoalOnboardPage() {
185+
return (
186+
<CreateGoalFormElement.Provider>
187+
<GoalOnboardContent />
188+
</CreateGoalFormElement.Provider>
189+
);
190+
}

src/app/(home)/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useAutoLogout } from '@/shared/hooks';
44
import { NavigationBar } from '@/shared/components/layout';
5-
import { NotifyOnboardModal } from '@/feature/onboard/notifyOnboardModal';
5+
import { GoalOnboardRedirect } from '@/feature/onboard';
66

77
interface HomeLayoutProps {
88
children?: React.ReactNode;
@@ -14,7 +14,7 @@ export default function HomePageLayout({ children }: HomeLayoutProps) {
1414
return (
1515
<div className="flex flex-1 max-sm:flex-col max-w-md w-full mx-auto h-full">
1616
<NavigationBar />
17-
{/* <NotifyOnboardModal /> */}
17+
<GoalOnboardRedirect />
1818
<div className="flex flex-1 flex-col overflow-x-hidden">{children}</div>
1919
</div>
2020
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './steps';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Image from 'next/image';
5+
import FlexBox from '@/shared/components/foundation/FlexBox';
6+
7+
interface CompleteStepProps {
8+
goalName: string;
9+
startDate?: string;
10+
endDate?: string;
11+
planet?: {
12+
name: string;
13+
image: {
14+
done: string;
15+
progress: string;
16+
};
17+
};
18+
}
19+
20+
const formatDateRange = (startDate?: string, endDate?: string): string => {
21+
if (!startDate || !endDate) return '';
22+
const start = new Date(startDate);
23+
const end = new Date(endDate);
24+
const days = ['일', '월', '화', '수', '목', '금', '토'];
25+
26+
const formatDate = (date: Date) => {
27+
const year = String(date.getFullYear()).slice(2);
28+
const month = String(date.getMonth() + 1).padStart(2, '0');
29+
const day = String(date.getDate()).padStart(2, '0');
30+
const dayName = days[date.getDay()];
31+
return `${year}.${month}.${day} (${dayName})`;
32+
};
33+
34+
return `${formatDate(start)} ~ ${formatDate(end)}`;
35+
};
36+
37+
export const CompleteStep = ({ goalName, startDate, endDate, planet }: CompleteStepProps) => {
38+
const [isImageLoaded, setIsImageLoaded] = useState(false);
39+
40+
return (
41+
<FlexBox direction="col" className="flex-1 items-center px-5 pt-12">
42+
<FlexBox direction="col" className="items-center gap-2 text-center mb-8">
43+
<h1 className="text-2xl font-bold text-white">나의 목표 행성이에요</h1>
44+
<p className="text-base text-text-secondary">목표를 끝까지 완료해 행성을 완성시키세요</p>
45+
</FlexBox>
46+
47+
<div className="relative w-[200px] h-[200px] mb-4">
48+
{/* 로딩 중 placeholder */}
49+
{!isImageLoaded && (
50+
<div className="absolute inset-0 flex items-center justify-center">
51+
<div className="w-[160px] h-[160px] rounded-full bg-fill-normal animate-pulse" />
52+
</div>
53+
)}
54+
<Image
55+
src={planet?.image.progress || '/goal/goal-progress.png'}
56+
alt="목표 행성"
57+
fill
58+
className={`object-contain transition-all duration-700 ease-out ${
59+
isImageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
60+
}`}
61+
priority
62+
onLoad={() => setIsImageLoaded(true)}
63+
/>
64+
</div>
65+
66+
<p
67+
className={`text-sm text-text-tertiary mb-8 transition-opacity duration-500 delay-300 ${
68+
isImageLoaded ? 'opacity-100' : 'opacity-0'
69+
}`}
70+
>
71+
{planet?.name ? `${planet.name} 행성` : '행성'}
72+
</p>
73+
74+
<div className="w-full bg-fill-normal rounded-2xl p-4">
75+
<h3 className="text-lg font-semibold text-white mb-2">{goalName}</h3>
76+
{startDate && endDate && (
77+
<p className="text-sm text-text-secondary">날짜 {formatDateRange(startDate, endDate)}</p>
78+
)}
79+
</div>
80+
</FlexBox>
81+
);
82+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client';
2+
3+
import Image from 'next/image';
4+
import FlexBox from '@/shared/components/foundation/FlexBox';
5+
import { CreateGoalFormElement } from '@/feature/goal';
6+
7+
export const DateStep = () => {
8+
return (
9+
<FlexBox direction="col" className="flex-1 px-5 pt-6 overflow-y-auto pb-24">
10+
<FlexBox direction="col" className="items-center mb-8">
11+
<div className="relative w-[236px] h-[190px] mb-4 -ml-5">
12+
<Image src="/goal-onboard/goal-onboard-3.png" alt="그로롱 캐릭터" fill className="object-contain" priority />
13+
</div>
14+
<h2 className="text-xl font-bold text-white text-center">언제까지 목표를 이뤄볼까?</h2>
15+
</FlexBox>
16+
17+
<FlexBox direction="col" className="gap-4">
18+
<CreateGoalFormElement.SelectStartDate />
19+
<CreateGoalFormElement.SelectEndDate />
20+
</FlexBox>
21+
22+
<p className="text-sm text-status-negative mt-4">ⓘ 목표 기간은 최소 1주, 최대 1년까지 설정 가능합니다.</p>
23+
</FlexBox>
24+
);
25+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client';
2+
3+
import Image from 'next/image';
4+
import FlexBox from '@/shared/components/foundation/FlexBox';
5+
import { CreateGoalFormElement } from '@/feature/goal';
6+
7+
export const GoalNameStep = () => {
8+
return (
9+
<FlexBox direction="col" className="flex-1 px-5 pt-6">
10+
<FlexBox direction="col" className="items-center mb-8">
11+
<div className="relative w-[236px] h-[190px] mb-4">
12+
<Image src="/goal-onboard/goal-onboard-2.png" alt="그로롱 캐릭터" fill className="object-contain" priority />
13+
</div>
14+
<h2 className="text-xl font-bold text-white text-center">어떤 목표를 이루고 싶어?</h2>
15+
</FlexBox>
16+
17+
<div className="w-full">
18+
<CreateGoalFormElement.Name />
19+
</div>
20+
</FlexBox>
21+
);
22+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
3+
import Image from 'next/image';
4+
5+
interface WelcomeStepProps {
6+
userName?: string;
7+
}
8+
9+
export const WelcomeStep = ({ userName = '' }: WelcomeStepProps) => {
10+
const displayName = userName || '친구';
11+
12+
return (
13+
<div className="relative flex-1 w-full h-full overflow-hidden">
14+
<Image
15+
src="/goal-onboard/goal-onboard-1.png"
16+
alt="목표 온보딩"
17+
fill
18+
className="object-cover object-top"
19+
priority
20+
/>
21+
{/* 텍스트 오버레이 */}
22+
<div className="absolute top-20 left-0 right-0 flex flex-col items-center text-center px-5 z-10">
23+
<h1 className="text-2xl font-bold text-white mb-3">{displayName}, 동행해줘서 고마워.</h1>
24+
<p className="text-base text-text-secondary whitespace-pre-line">
25+
{`이제 그로롱과 함께 목표 행성을\n모으러 떠나보자!`}
26+
</p>
27+
</div>
28+
</div>
29+
);
30+
};

0 commit comments

Comments
 (0)