Skip to content

Commit 5f5297a

Browse files
committed
feat: 레시피 상세/조리 단계를 목업 대신 실제 백엔드 연동으로 교체
RecipeDetailPage·CookingStepsPage가 lib/recipes.ts의 하드코딩된 3개 목업 대신 POST /recommend(recipe_id 포함) 응답으로 재료 분류(필수/생략가능)와 조리 단계를 채우도록 변경. RecipeDetailPage에 로딩/에러 상태 UI를 추가하고, 조리 시작 시 받아온 steps/name을 라우터 state로 다음 페이지에 전달해 재요청을 줄인다.
1 parent cdc3921 commit 5f5297a

4 files changed

Lines changed: 170 additions & 69 deletions

File tree

src/lib/api.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,35 @@ export interface RecipeSummaryDto {
7272
missing_ingredients: string[];
7373
}
7474

75+
export interface SubstituteDto {
76+
ingredient_name: string;
77+
substitute_name: string;
78+
note: string | null;
79+
allergy_conflict: boolean;
80+
}
81+
82+
export interface ClassificationDto {
83+
required: string[];
84+
optional: string[];
85+
reason: string | null;
86+
}
87+
88+
export interface RecipeDetailDto {
89+
recipe_id: string;
90+
name: string;
91+
cooking_time: number | null;
92+
difficulty: string | null;
93+
category: string | null;
94+
cooking_method: string | null;
95+
missing_ingredients: string[];
96+
classification: ClassificationDto | null;
97+
substitutes: SubstituteDto[];
98+
cooking_steps: string[];
99+
}
100+
75101
export interface RecommendResponseDto {
76102
recipes: RecipeSummaryDto[];
77-
detail: unknown | null;
103+
detail: RecipeDetailDto | null;
78104
message: string;
79105
}
80106

@@ -129,11 +155,16 @@ export function getAllergens(): Promise<AllergenDto[]> {
129155
export async function recommend(
130156
ingredientIds: string[],
131157
allergenIds: string[],
158+
recipeId?: string,
132159
): Promise<RecommendResponseDto> {
133160
const response = await fetch(`${API_URL}/recommend`, {
134161
method: 'POST',
135162
headers: { 'Content-Type': 'application/json' },
136-
body: JSON.stringify({ ingredient_ids: ingredientIds, allergen_ids: allergenIds }),
163+
body: JSON.stringify({
164+
ingredient_ids: ingredientIds,
165+
allergen_ids: allergenIds,
166+
...(recipeId ? { recipe_id: recipeId } : {}),
167+
}),
137168
});
138169
if (!response.ok) {
139170
throw new Error(`레시피 추천 요청이 실패했어요 (${response.status})`);

src/lib/recipes.ts

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/pages/CookingStepsPage.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import { useEffect, useState } from 'react';
2-
import { useNavigate } from 'react-router-dom';
2+
import { useLocation, useNavigate } from 'react-router-dom';
33
import { PrimaryButton } from '../components/PrimaryButton';
44
import { useVoiceRecorder } from '../hooks/useVoiceRecorder';
55
import { askTuiStream } from '../lib/askTuiStream';
6+
import { recommend } from '../lib/api';
67
import { transcribeAudio } from '../lib/googleStt';
7-
import { getRecipe } from '../lib/recipes';
8-
import { getAllergies, getSelectedRecipe, getSelectedRecipeId } from '../lib/storage';
8+
import {
9+
getAllergies,
10+
getIngredients,
11+
getSelectedRecipe,
12+
getSelectedRecipeId,
13+
} from '../lib/storage';
914
import { colors, shadow } from '../theme';
1015

16+
interface CookingStepsNavState {
17+
steps?: string[];
18+
name?: string;
19+
}
20+
1121
export function CookingStepsPage() {
1222
const navigate = useNavigate();
13-
const [recipeName, setRecipeName] = useState('두부계란덮밥');
14-
const [steps, setSteps] = useState<string[]>([]);
23+
const location = useLocation();
24+
const navState = (location.state as CookingStepsNavState | null) ?? null;
25+
const [recipeName, setRecipeName] = useState(navState?.name ?? getSelectedRecipe());
26+
const [steps, setSteps] = useState<string[]>(navState?.steps ?? []);
1527
const [stepIndex, setStepIndex] = useState(0);
1628
const [question, setQuestion] = useState('');
1729
const [isAsking, setIsAsking] = useState(false);
@@ -22,10 +34,22 @@ export function CookingStepsPage() {
2234
const recorder = useVoiceRecorder();
2335

2436
useEffect(() => {
25-
const name = getSelectedRecipe();
26-
setRecipeName(name);
27-
setSteps(getRecipe(name).steps);
28-
}, []);
37+
if (navState?.steps?.length) return;
38+
39+
const ingredientIds = getIngredients().map((i) => i.id);
40+
const allergenIds = getAllergies().selected.map((a) => a.id);
41+
const recipeId = getSelectedRecipeId();
42+
43+
recommend(ingredientIds, allergenIds, recipeId)
44+
.then((result) => {
45+
if (!result.detail) return;
46+
setRecipeName(result.detail.name);
47+
setSteps(result.detail.cooking_steps);
48+
})
49+
.catch(() => {
50+
// 조리 단계를 못 불러와도 화면은 유지하고, 아래 질문 기능은 계속 쓸 수 있게 둔다.
51+
});
52+
}, [navState]);
2953

3054
const total = steps.length || 1;
3155
const isLast = stepIndex >= total - 1;

src/pages/RecipeDetailPage.tsx

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,88 @@ import { useNavigate } from 'react-router-dom';
33
import iconFaceChef from '../assets/icon-face-chef.png';
44
import { BackHeader } from '../components/BackHeader';
55
import { PrimaryButton } from '../components/PrimaryButton';
6-
import { getRecipe, type Recipe } from '../lib/recipes';
7-
import { getSelectedRecipe } from '../lib/storage';
6+
import { recommend, type RecipeDetailDto } from '../lib/api';
7+
import { getAllergies, getIngredients, getSelectedRecipe, getSelectedRecipeId } from '../lib/storage';
88
import { colors, shadow } from '../theme';
99

1010
export function RecipeDetailPage() {
1111
const navigate = useNavigate();
12-
const [recipeName, setRecipeName] = useState('두부계란덮밥');
13-
const [recipe, setRecipe] = useState<Recipe | null>(null);
12+
const [recipeName, setRecipeName] = useState(getSelectedRecipe());
13+
const [detail, setDetail] = useState<RecipeDetailDto | null>(null);
14+
const [phase, setPhase] = useState<'loading' | 'done' | 'error'>('loading');
15+
const [error, setError] = useState('');
1416

1517
useEffect(() => {
16-
const name = getSelectedRecipe();
17-
setRecipeName(name);
18-
setRecipe(getRecipe(name));
18+
const ingredientIds = getIngredients().map((i) => i.id);
19+
const allergenIds = getAllergies().selected.map((a) => a.id);
20+
const recipeId = getSelectedRecipeId();
21+
22+
recommend(ingredientIds, allergenIds, recipeId)
23+
.then((result) => {
24+
if (!result.detail) {
25+
throw new Error('레시피 상세 정보를 불러오지 못했어요.');
26+
}
27+
setDetail(result.detail);
28+
setRecipeName(result.detail.name);
29+
setPhase('done');
30+
})
31+
.catch((err) => {
32+
setError(err instanceof Error ? err.message : '레시피 상세 정보를 불러오지 못했어요.');
33+
setPhase('error');
34+
});
1935
}, []);
2036

21-
const steps = recipe?.steps ?? [];
37+
const steps = detail?.cooking_steps ?? [];
38+
const requiredIngredients = detail?.classification?.required ?? detail?.missing_ingredients ?? [];
39+
const optionalIngredients = detail?.classification?.optional ?? [];
40+
41+
if (phase === 'loading') {
42+
return (
43+
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
44+
<BackHeader title="레시피 상세" onBack={() => navigate('/recipes/loading')} maxWidth={560} />
45+
<div
46+
style={{
47+
flex: 1,
48+
display: 'flex',
49+
alignItems: 'center',
50+
justifyContent: 'center',
51+
fontSize: 14,
52+
color: colors.textMuted,
53+
}}
54+
>
55+
레시피 정보를 불러오는 중이에요..
56+
</div>
57+
</div>
58+
);
59+
}
60+
61+
if (phase === 'error') {
62+
return (
63+
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
64+
<BackHeader title="레시피 상세" onBack={() => navigate('/recipes/loading')} maxWidth={560} />
65+
<div
66+
style={{
67+
flex: 1,
68+
display: 'flex',
69+
flexDirection: 'column',
70+
alignItems: 'center',
71+
justifyContent: 'center',
72+
gap: 12,
73+
padding: '0 24px',
74+
textAlign: 'center',
75+
}}
76+
>
77+
<div style={{ fontSize: 15, color: colors.allergyText, wordBreak: 'keep-all' }}>{error}</div>
78+
<div
79+
onClick={() => navigate('/recipes/loading')}
80+
style={{ fontSize: 13, fontWeight: 800, color: colors.teal, cursor: 'pointer' }}
81+
>
82+
다시 시도하기
83+
</div>
84+
</div>
85+
</div>
86+
);
87+
}
2288

2389
return (
2490
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
@@ -68,7 +134,7 @@ export function RecipeDetailPage() {
68134
borderRadius: 999,
69135
}}
70136
>
71-
<span>{recipe?.time}</span>
137+
<span>{detail?.cooking_time != null ? `${detail.cooking_time}분` : '-'}</span>
72138
<span>·</span>
73139
<span>{steps.length}단계</span>
74140
</div>
@@ -86,8 +152,8 @@ export function RecipeDetailPage() {
86152
<div style={{ fontSize: 13, fontWeight: 700, color: colors.navy, marginBottom: 12 }}>
87153
필요한 재료
88154
</div>
89-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
90-
{(recipe?.ingredients ?? []).map((item) => (
155+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: optionalIngredients.length ? 16 : 0 }}>
156+
{requiredIngredients.map((item) => (
91157
<div
92158
key={item}
93159
style={{
@@ -103,6 +169,30 @@ export function RecipeDetailPage() {
103169
</div>
104170
))}
105171
</div>
172+
{optionalIngredients.length > 0 && (
173+
<>
174+
<div style={{ fontSize: 13, fontWeight: 700, color: colors.navy, marginBottom: 12 }}>
175+
생략 가능한 재료
176+
</div>
177+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
178+
{optionalIngredients.map((item) => (
179+
<div
180+
key={item}
181+
style={{
182+
padding: '8px 14px',
183+
borderRadius: 999,
184+
background: colors.bg,
185+
color: colors.textMuted,
186+
fontSize: 13,
187+
fontWeight: 700,
188+
}}
189+
>
190+
{item}
191+
</div>
192+
))}
193+
</div>
194+
</>
195+
)}
106196
</div>
107197

108198
<div
@@ -145,7 +235,9 @@ export function RecipeDetailPage() {
145235
</div>
146236
</div>
147237

148-
<PrimaryButton onClick={() => navigate('/cooking/steps')}>
238+
<PrimaryButton
239+
onClick={() => navigate('/cooking/steps', { state: { steps, name: recipeName } })}
240+
>
149241
요리 시작하기
150242
</PrimaryButton>
151243
</div>

0 commit comments

Comments
 (0)