diff --git a/src/components/modals/PaymentMethodModal.tsx b/src/components/modals/PaymentMethodModal.tsx index 5dc3c0a..c68a7fd 100644 --- a/src/components/modals/PaymentMethodModal.tsx +++ b/src/components/modals/PaymentMethodModal.tsx @@ -1,4 +1,6 @@ -import React, {useState} from 'react'; +// 내 플랜 보기로 재활용 + +import React, { useState, useEffect } from "react"; import { View, Text, @@ -6,245 +8,266 @@ import { TouchableOpacity, StyleSheet, ScrollView, - TextInput, Alert, -} from 'react-native'; -import {Ionicons as Icon} from '@expo/vector-icons'; -import {colors} from '../../theme/colors'; + ActivityIndicator, +} from "react-native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { myPlanAPI, SubscriptionResponse } from "../../services/myPlanAPI"; +// ✅ 1. Props 인터페이스 수정: onCancelSuccess 추가 interface PaymentMethodModalProps { isOpen: boolean; onClose: () => void; + onCancelSuccess?: () => void; // 이 부분이 있어야 MyPageScreen에서 함수를 넘겨줄 수 있습니다. } const PaymentMethodModal: React.FC = ({ isOpen, onClose, + onCancelSuccess, // ✅ 2. Props로 받기 }) => { - const [showAddForm, setShowAddForm] = useState(false); - const [newCard, setNewCard] = useState({ - cardNumber: '', - expiryDate: '', - cvc: '', - cardholderName: '', - }); + const [subscription, setSubscription] = useState( + null + ); + const [isLoading, setIsLoading] = useState(false); - const paymentMethods = [ - { - id: 1, - cardType: '신한카드', - lastFourDigits: '1234', - expiryDate: '12/25', - isDefault: true, - }, - { - id: 2, - cardType: '국민카드', - lastFourDigits: '5678', - expiryDate: '08/26', - isDefault: false, - }, - ]; + // 모달이 열릴 때 데이터 조회 + useEffect(() => { + if (isOpen) { + fetchSubscriptionData(); + } + }, [isOpen]); - const handleAddCard = () => { - Alert.alert('결제 수단 추가', '결제 수단이 추가되었습니다!'); - setShowAddForm(false); - setNewCard({cardNumber: '', expiryDate: '', cvc: '', cardholderName: ''}); + const fetchSubscriptionData = async () => { + try { + setIsLoading(true); + const data = await myPlanAPI.getMySubscription(); + setSubscription(data); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } }; - const handleDeleteCard = (id: number) => { - Alert.alert('삭제', '이 결제 수단을 삭제하시겠습니까?', [ - {text: '취소', style: 'cancel'}, - { - text: '삭제', - style: 'destructive', - onPress: () => { - Alert.alert('삭제 완료', '결제 수단이 삭제되었습니다.'); + // ✅ 구독 취소 핸들러 + const handleCancelSubscription = () => { + Alert.alert( + "구독 취소", + "정말로 구독을 취소하시겠습니까?\n취소 시 보안을 위해 로그아웃됩니다.", + [ + { text: "유지하기", style: "cancel" }, + { + text: "취소하기", + style: "destructive", + onPress: async () => { + try { + setIsLoading(true); + + // 1. 취소 API 호출 + const response = await myPlanAPI.cancelSubscription(); + + if (response.success) { + // 2. 성공 알림 및 로그아웃 실행 + Alert.alert( + "알림", + "구독이 정상적으로 취소되었습니다.\n로그아웃 됩니다.", + [ + { + text: "확인", + onPress: () => { + onClose(); // 모달 닫기 + // ✅ 3. 부모(MyPageScreen)에서 받은 로그아웃 함수 실행 + if (onCancelSuccess) { + onCancelSuccess(); + } + }, + }, + ] + ); + } + } catch (error: any) { + Alert.alert( + "오류", + error.message || "취소 요청 중 문제가 발생했습니다." + ); + } finally { + setIsLoading(false); + } + }, }, - }, - ]); + ] + ); }; - const handleSetDefault = (id: number) => { - Alert.alert('기본 설정', '기본 결제 수단이 변경되었습니다.'); + // 날짜 포맷팅 (YYYY.MM.DD) + const formatDate = (dateString: string | null) => { + if (!dateString) return "-"; + const date = new Date(dateString); + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart( + 2, + "0" + )}.${String(date.getDate()).padStart(2, "0")}`; }; - const formatCardNumber = (value: string) => { - const numbers = value.replace(/\D/g, ''); - const groups = numbers.match(/.{1,4}/g); - return groups ? groups.join(' ') : numbers; + // 플랜 코드 한글 변환 + const getPlanName = (code: string | null) => { + switch (code) { + case "PREMIUM_MONTHLY": + return "프리미엄 월간 멤버십"; + default: + return "프리미엄 연간 멤버십"; + } }; - const formatExpiryDate = (value: string) => { - const numbers = value.replace(/\D/g, ''); - if (numbers.length >= 2) { - return numbers.slice(0, 2) + '/' + numbers.slice(2, 4); + // 결제 수단 한글 변환 + const getProviderName = (provider: string | null) => { + switch (provider) { + case "KAKAOPAY": + return "카카오페이"; + case "NAVERPAY": + return "네이버페이"; + + default: + return provider || "-"; } - return numbers; }; + // 현재 활성 구독 여부 확인 + const hasActive = subscription?.hasActiveSubscription; + return ( + onRequestClose={onClose} + > + {/* 헤더 */} - 결제 수단 관리 + 내 구독 정보 - + - - - 등록된 결제 수단 - {paymentMethods.length === 0 ? ( - 등록된 결제 수단이 없습니다 - ) : ( - - {paymentMethods.map(method => ( - - - - - - {method.cardType} - {method.isDefault && ( - - 기본 - - )} + {/* 로딩 상태 표시 */} + {isLoading ? ( + + + 처리 중... + + ) : ( + + {/* ✅ 활성 구독이 있을 때만 정보 표시 */} + {subscription && hasActive ? ( + <> + {/* 1. 플랜 정보 카드 */} + + 이용 중인 상품 + + + + + + + {getPlanName(subscription.planCode)} + + + + {subscription.status === "active" + ? "이용중" + : subscription.status} + - - **** **** **** {method.lastFourDigits} + + + + + + {/* 상세 정보 */} + + + 시작일 + + {formatDate(subscription.startedAt)} - - 유효기간: {method.expiryDate} + + + 다음 결제일 + + {formatDate(subscription.expiredAt)} - - {!method.isDefault && ( - - handleSetDefault(method.id)}> - - 기본으로 설정 - + + 결제 수단 + + + + {getProviderName(subscription.provider)} + + - )} + - ))} - - )} - - setShowAddForm(!showAddForm)}> - - 새 결제 수단 추가 - - - - {showAddForm && ( - - 카드 정보 입력 - - 카드 번호 - - setNewCard({ - ...newCard, - cardNumber: formatCardNumber(text), - }) - } - maxLength={19} - /> - - - - 유효기간 - - setNewCard({ - ...newCard, - expiryDate: formatExpiryDate(text), - }) - } - maxLength={5} - keyboardType="numeric" - /> - - CVC - - setNewCard({ - ...newCard, - cvc: text.replace(/\D/g, ''), - }) - } - maxLength={3} - keyboardType="numeric" - /> + + {/* 2. 취소 버튼 */} + + + 구독 취소 + + + ) : ( + /* ✅ 구독 정보가 없거나 취소된 경우 */ + + {subscription?.status === "canceled" ? ( + <> + + + 구독이 취소되었습니다. + + + ) : ( + <> + + + 사용 중인 구독 상품이 없습니다. + + + )} - - 카드 소유자명 - - setNewCard({...newCard, cardholderName: text}) - } - /> - - - setShowAddForm(false)}> - 취소 - - - 추가하기 - - - - )} + )} - {/* 안내 사항 */} - - 💳 안전한 결제 - - - • 모든 결제 정보는 암호화되어 안전하게 저장됩니다 - - - • PCI-DSS 인증을 받은 결제 시스템을 사용합니다 - - - • 카드 정보는 절대 외부에 공유되지 않습니다 - + {/* 3. 하단 안내 문구 */} + + 💡 멤버십 안내 + + + • 결제는 매월/매년 수동으로 진행 해야 합니다. + + - - + + )} @@ -254,234 +277,165 @@ const PaymentMethodModal: React.FC = ({ const styles = StyleSheet.create({ overlay: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', + backgroundColor: "rgba(0, 0, 0, 0.7)", + justifyContent: "center", + alignItems: "center", padding: 20, }, modalContent: { - backgroundColor: '#2a2a2a', + backgroundColor: "#2a2a2a", borderRadius: 16, - width: '100%', + width: "100%", maxWidth: 400, - maxHeight: '85%', + maxHeight: "80%", + overflow: "hidden", }, header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", padding: 16, paddingHorizontal: 20, borderBottomWidth: 1, - borderBottomColor: '#404040', - flexShrink: 0, + borderBottomColor: "#404040", }, title: { - fontSize: 17, - fontWeight: '600', - color: '#ffffff', + fontSize: 18, + fontWeight: "700", + color: "#ffffff", }, closeBtn: { - width: 32, - height: 32, - justifyContent: 'center', - alignItems: 'center', padding: 4, }, + loadingContainer: { + padding: 40, + alignItems: "center", + justifyContent: "center", + gap: 12, + }, + loadingText: { + color: "#cccccc", + fontSize: 14, + }, body: { padding: 20, }, section: { - marginBottom: 20, + marginBottom: 24, }, sectionTitle: { fontSize: 15, - fontWeight: '600', - color: '#ffffff', - marginBottom: 12, - }, - emptyText: { - color: '#999999', - fontSize: 14, - textAlign: 'center', - paddingVertical: 40, - paddingHorizontal: 20, - }, - methodsList: { - gap: 12, - marginBottom: 12, + fontWeight: "600", + color: "#cccccc", + marginBottom: 10, + marginLeft: 4, }, - methodItem: { - backgroundColor: 'rgba(255, 255, 255, 0.05)', + planCard: { + backgroundColor: "rgba(255, 255, 255, 0.05)", borderWidth: 1, - borderColor: '#404040', - borderRadius: 10, - padding: 14, + borderColor: "#4ade80", + borderRadius: 12, + padding: 20, }, - methodInfo: { - flexDirection: 'row', - gap: 12, - marginBottom: 10, + planHeader: { + flexDirection: "row", + alignItems: "center", + gap: 14, + marginBottom: 16, }, - methodHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 4, + planTitleContainer: { + flex: 1, }, - cardType: { - fontSize: 15, - fontWeight: '600', - color: '#ffffff', + planName: { + fontSize: 18, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, }, - defaultBadge: { - backgroundColor: '#4ade80', + statusBadge: { + backgroundColor: "#4ade80", + alignSelf: "flex-start", paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 10, - }, - defaultBadgeText: { - color: '#000000', - fontSize: 10, - fontWeight: '600', - }, - cardNumber: { - fontSize: 13, - color: '#cccccc', - marginBottom: 2, + paddingVertical: 3, + borderRadius: 6, }, - cardExpiry: { + statusText: { + color: "#000000", fontSize: 11, - color: '#999999', - }, - methodActions: { - flexDirection: 'row', - gap: 8, - }, - addBtn: { - width: '100%', - padding: 12, - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderWidth: 1, - borderStyle: 'dashed', - borderColor: '#555555', - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, + fontWeight: "700", }, - addBtnText: { - color: '#4ade80', - fontSize: 14, - fontWeight: '600', - }, - addForm: { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderWidth: 1, - borderColor: '#404040', - borderRadius: 10, - padding: 16, - marginBottom: 20, - }, - formTitle: { - fontSize: 15, - fontWeight: '600', - color: '#ffffff', + divider: { + height: 1, + backgroundColor: "#404040", marginBottom: 16, }, - formGroup: { - marginBottom: 12, - }, - formRow: { - flexDirection: 'row', + infoGrid: { gap: 12, }, - label: { - fontSize: 12, - fontWeight: '500', - color: '#cccccc', - marginBottom: 6, + infoRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", }, - input: { - backgroundColor: '#404040', - borderWidth: 1, - borderColor: '#555555', - borderRadius: 6, - padding: 10, - paddingHorizontal: 12, + infoLabel: { + fontSize: 14, + color: "#999999", + }, + infoValue: { fontSize: 14, - color: '#ffffff', + color: "#ffffff", + fontWeight: "500", }, - formActions: { - flexDirection: 'row', - gap: 8, - marginTop: 16, + providerContainer: { + flexDirection: "row", + alignItems: "center", + gap: 6, }, cancelBtn: { - flex: 1, - padding: 10, - backgroundColor: '#404040', - borderRadius: 6, - alignItems: 'center', + width: "100%", + padding: 14, + backgroundColor: "rgba(239, 68, 68, 0.15)", + borderWidth: 1, + borderColor: "#ef4444", + borderRadius: 10, + alignItems: "center", }, cancelBtnText: { - color: '#ffffff', - fontSize: 14, - fontWeight: '600', + color: "#ef4444", + fontSize: 15, + fontWeight: "600", }, - submitBtn: { - flex: 1, - padding: 10, - backgroundColor: '#4ade80', - borderRadius: 6, - alignItems: 'center', + emptyContainer: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 40, + gap: 16, }, - submitBtnText: { - color: '#000000', - fontSize: 14, - fontWeight: '600', + emptyText: { + color: "#999999", + fontSize: 15, + marginTop: 10, }, - paymentInfo: { - backgroundColor: 'rgba(74, 222, 128, 0.1)', - borderWidth: 1, - borderColor: '#4ade80', + infoBox: { + backgroundColor: "rgba(74, 222, 128, 0.05)", borderRadius: 10, - padding: 14, + padding: 16, + marginTop: 0, }, - paymentInfoTitle: { - color: '#4ade80', + infoBoxTitle: { + color: "#4ade80", fontSize: 14, - fontWeight: '600', + fontWeight: "600", marginBottom: 10, }, - paymentInfoList: { + infoList: { gap: 6, }, - paymentInfoItem: { - color: '#cccccc', + infoItem: { + color: "#cccccc", fontSize: 12, lineHeight: 18, }, - defaultBtn: { - flex: 1, - paddingVertical: 8, - paddingHorizontal: 12, - borderWidth: 1, - borderColor: '#4ade80', - borderRadius: 6, - backgroundColor: 'rgba(74, 222, 128, 0.1)', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 4, - }, - defaultBtnText: { - color: '#4ade80', - fontSize: 12, - fontWeight: '500', - }, }); export default PaymentMethodModal; - diff --git a/src/screens/diet/MealRecommendScreen.tsx b/src/screens/diet/MealRecommendScreen.tsx index d078812..64d98d5 100644 --- a/src/screens/diet/MealRecommendScreen.tsx +++ b/src/screens/diet/MealRecommendScreen.tsx @@ -236,6 +236,227 @@ const loadingStyles = StyleSheet.create({ }, }); +// ✅ 끼니 선택 모달 +const MealsSelectionModal = ({ + visible, + currentMeals, + onSelect, + onClose, +}: { + visible: boolean; + currentMeals: number; + onSelect: (meals: number) => void; + onClose: () => void; +}) => { + return ( + + + + + + {/* 헤더 */} + + + 끼니 수 선택 + + + + 하루에 몇 끼를 드시나요? + + + {/* 끼니 선택 버튼들 */} + + {[1, 2, 3].map((num) => ( + { + onSelect(num); + onClose(); + }} + activeOpacity={0.8} + > + + + + {currentMeals === num ? ( + + ) : ( + + )} + + + + {num}끼 + + + {num === 1 + ? "하루 1끼" + : num === 2 + ? "아침 + 점심 또는 점심 + 저녁" + : "아침 + 점심 + 저녁"} + + + + + + ))} + + + {/* 닫기 버튼 */} + + 취소 + + + + + + + ); +}; + +const mealsModalStyles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.8)", + justifyContent: "center", + alignItems: "center", + }, + container: { + width: "85%", + maxWidth: 400, + }, + content: { + borderRadius: 24, + padding: 28, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 12, + marginBottom: 12, + }, + title: { + fontSize: 24, + fontWeight: "800", + color: "#ffffff", + letterSpacing: 0.5, + }, + subtitle: { + fontSize: 15, + color: "#9ca3af", + textAlign: "center", + marginBottom: 28, + letterSpacing: 0.3, + }, + optionsContainer: { + gap: 12, + marginBottom: 24, + }, + optionButton: { + borderRadius: 16, + overflow: "hidden", + }, + optionGradient: { + borderRadius: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + optionContent: { + flexDirection: "row", + alignItems: "center", + padding: 18, + gap: 16, + }, + optionIconContainer: { + width: 48, + height: 48, + alignItems: "center", + justifyContent: "center", + }, + optionTextContainer: { + flex: 1, + }, + optionNumber: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + letterSpacing: 0.3, + }, + optionNumberActive: { + color: "#111827", + }, + optionDesc: { + fontSize: 13, + color: "#9ca3af", + letterSpacing: 0.2, + }, + optionDescActive: { + color: "rgba(17,24,39,0.7)", + }, + closeButton: { + paddingVertical: 14, + alignItems: "center", + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + closeButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#9ca3af", + letterSpacing: 0.3, + }, +}); + const transformTempMealToUI = (tempDay: any, dayIndex: number) => { console.log(`🔄 ${dayIndex}일차 변환 시작`); @@ -327,7 +548,7 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { carbs: Math.round(f.carbs), protein: Math.round(f.protein), fat: Math.round(f.fat), - liked: false, // ✅ 추가 + liked: false, })) || [], calories: Math.round(breakfast?.totalCalories || 0), carbs: Math.round(breakfast?.totalCarbs || 0), @@ -342,7 +563,7 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { carbs: Math.round(f.carbs), protein: Math.round(f.protein), fat: Math.round(f.fat), - liked: false, // ✅ 추가 + liked: false, })) || [], calories: Math.round(lunch?.totalCalories || 0), carbs: Math.round(lunch?.totalCarbs || 0), @@ -357,7 +578,7 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { carbs: Math.round(f.carbs), protein: Math.round(f.protein), fat: Math.round(f.fat), - liked: false, // ✅ 추가 + liked: false, })) || [], calories: Math.round(dinner?.totalCalories || 0), carbs: Math.round(dinner?.totalCarbs || 0), @@ -370,7 +591,7 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { const MealRecommendScreen = () => { const navigation = useNavigation(); const [screen, setScreen] = useState< - "welcome" | "excludedIngredients" | "meals" + "welcome" | "excludedIngredients" | "meals" >("welcome"); const [weeklyMeals, setWeeklyMeals] = useState([]); @@ -381,6 +602,10 @@ const MealRecommendScreen = () => { const [savedMeals, setSavedMeals] = useState([]); const [currentPlanId, setCurrentPlanId] = useState(null); + // ✅ 끼니 수 관련 state + const [mealsPerDay, setMealsPerDay] = useState(3); + const [showMealsModal, setShowMealsModal] = useState(false); + const fadeAnim = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -499,8 +724,9 @@ const MealRecommendScreen = () => { try { console.log("🍽️ 임시 식단 생성 시작"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); - const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(); + const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); console.log("=== 📦 API 응답 원본 ==="); console.log("응답 배열 길이:", tempMeals.length); @@ -573,17 +799,17 @@ const MealRecommendScreen = () => { try { console.log("🍽️ 1일 식단 생성 시작"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); - const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(); + const tempMeals = await recommendedMealAPI.getDailyMealPlan(mealsPerDay); if (!tempMeals || tempMeals.length === 0) { throw new Error("식단 생성에 실패했습니다."); } - const singleDay = [tempMeals[0]]; console.log("✅ 1일 식단 생성 완료"); - const weekData = singleDay.map((tempDay, index) => + const weekData = tempMeals.map((tempDay, index) => transformTempMealToUI(tempDay, index + 1) ); @@ -962,6 +1188,17 @@ const MealRecommendScreen = () => { onCancel={handleCancelLoading} /> + {/* ✅ 끼니 선택 모달 */} + { + setMealsPerDay(num); + console.log(`✅ 끼니 수 변경: ${num}끼`); + }} + onClose={() => setShowMealsModal(false)} + /> + navigation.goBack()} @@ -1069,6 +1306,28 @@ const MealRecommendScreen = () => { + + {/* ✅ 4. 끼니 수정하기 */} + setShowMealsModal(true)} + activeOpacity={0.8} + > + + + + 끼니 수정하기 ({mealsPerDay}끼) + + + {excludedIngredients.length > 0 && ( @@ -2627,7 +2886,6 @@ const styles = StyleSheet.create({ borderColor: "rgba(227,255,124,0.2)", borderRadius: 14, }, - // ✅ 좋아요 기능을 위한 새 스타일 mealTagContent: { flexDirection: "row", alignItems: "center", @@ -2770,4 +3028,4 @@ const styles = StyleSheet.create({ }, }); -export default MealRecommendScreen; \ No newline at end of file +export default MealRecommendScreen; diff --git a/src/screens/main/MyPageScreen.tsx b/src/screens/main/MyPageScreen.tsx index 5e5c955..fa01c98 100644 --- a/src/screens/main/MyPageScreen.tsx +++ b/src/screens/main/MyPageScreen.tsx @@ -24,6 +24,7 @@ import DeleteAccountModal from "../../components/modals/DeleteAccountModal"; import PremiumModal from "../../components/modals/PremiumModal"; import { useRoute } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native"; +import { getProfile } from "@react-native-seoul/kakao-login"; const MyPageScreen = ({ navigation }: any) => { const route = useRoute(); @@ -48,8 +49,7 @@ const MyPageScreen = ({ navigation }: any) => { useState(false); const [isPremiumModalOpen, setIsPremiumModalOpen] = useState(false); - // 1. 외부에서 넘어온 파라미터 감지 (내 플랜 모달 열기) - + // 1. 외부에서 넘어온 파라미터 감지 (내 플랜 모달 열기) useEffect(() => { if (route.params?.openPremiumModal) { console.log("🔓 마이페이지 진입: 프리미엄 모달 자동 오픈"); @@ -58,7 +58,7 @@ const MyPageScreen = ({ navigation }: any) => { } }, [route.params]); - // 2. 초기 데이터 로드 + // 2. 초기 데이터 로드 useFocusEffect( React.useCallback(() => { console.log("🔄 마이페이지 포커스 - 데이터 새로고침"); @@ -80,7 +80,7 @@ const MyPageScreen = ({ navigation }: any) => { } }; - // 멤버십 타입 로드 + // 멤버십 타입 로드 const loadMembershipType = async () => { try { const membershipType = await AsyncStorage.getItem("membershipType"); @@ -92,7 +92,7 @@ const MyPageScreen = ({ navigation }: any) => { } }; - // 테스트용 멤버십 전환 함수 + // 테스트용 멤버십 전환 함수 const handleToggleMembership = async () => { const newType = currentMembershipType === "FREE" ? "PREMIUM" : "FREE"; @@ -164,19 +164,30 @@ const MyPageScreen = ({ navigation }: any) => { ); }; - const handleLogout = () => { + // ✅ 로그아웃 실행 함수 (즉시 실행용) + const executeLogout = async () => { + try { + await authAPI.logout(); + navigation.replace("Login"); + } catch (e) { + console.error(e); + navigation.replace("Login"); // 에러나도 일단 화면 이동 + } + }; + + // 버튼 클릭 시 확인창 띄우는 로그아웃 핸들러 + const handleLogoutPress = () => { Alert.alert("로그아웃", "정말로 로그아웃하시겠습니까?", [ { text: "취소", style: "cancel" }, - { - text: "확인", - onPress: async () => { - await authAPI.logout(); - navigation.replace("Login"); - }, - }, + { text: "확인", onPress: executeLogout }, ]); }; + // 단순 호출용 핸들러 (사용 안 함, handleLogoutPress 사용 권장) + const handleLogout = () => { + executeLogout(); + }; + const handleDeleteAccount = () => { setIsDeleteAccountModalOpen(true); }; @@ -249,7 +260,7 @@ const MyPageScreen = ({ navigation }: any) => { {getMembershipTypeText(currentMembershipType)} - {/* 현재 멤버십 상태 표시 */} + {/* 현재 멤버십 상태 표시 */} { 🧪 개발 테스트 (개발 전용) - {/* 멤버십 전환 버튼 */} + {/* 멤버십 전환 버튼 */} { ⚙️ 계정 관리 - + {/* ✅ 버튼에 확인창 있는 로그아웃 연결 */} + 로그아웃 { {/* 모달들 */} + + {/* ✅ 1. 구독 정보 모달 (로그아웃 기능 연결됨) */} + setIsPaymentMethodModalOpen(false)} + onCancelSuccess={executeLogout} + /> + setIsAIAnalysisModalOpen(false)} /> + setIsMyPlanModalOpen(false)} navigation={navigation} /> - setIsPaymentMethodModalOpen(false)} - /> + + {/* ⚠️ 중복되었던 PaymentMethodModal 제거됨 */} + setIsProfileEditModalOpen(false)} diff --git a/src/services/myPlanAPI.ts b/src/services/myPlanAPI.ts new file mode 100644 index 0000000..f028736 --- /dev/null +++ b/src/services/myPlanAPI.ts @@ -0,0 +1,61 @@ +import { request } from "./apiConfig"; + +export interface SubscriptionResponse { + hasActiveSubscription: boolean; + status: string; + planCode: string | null; + startedAt: string | null; + expiredAt: string | null; + canceledAt: string | null; + provider: string | null; + providerCustomerId: string | null; + providerSubscriptionId: string | null; +} + +// ✅ 취소 응답 타입 정의 (서버가 주는 JSON 구조에 맞춤) +export interface CancelSubscriptionResponse { + success: boolean; + message: string; + subscription: SubscriptionResponse; // 취소된 후의 상태 정보 +} + +export const myPlanAPI = { + /** + * 내 구독 정보 조회 + * GET /api/subscriptions/me + */ + getMySubscription: async (): Promise => { + try { + const response = await request( + "/api/subscriptions/me", + { method: "GET" } + ); + return response; + } catch (error: any) { + throw new Error(error.message || "구독 정보를 불러오는데 실패했습니다."); + } + }, + + /** + * ✅ 구독 취소 + * POST /api/subscriptions/cancel + */ + cancelSubscription: async (): Promise => { + try { + console.log("🚫 구독 취소 요청"); + + const response = await request( + "/api/subscriptions/cancel", + { + method: "POST", + } + ); + + console.log("✅ 구독 취소 완료:", response.message); + return response; + } catch (error: any) { + console.error("❌ 구독 취소 실패:", error); + throw new Error(error.message || "구독 취소에 실패했습니다."); + } + }, +}; diff --git a/src/services/recommendedMealAPI.ts b/src/services/recommendedMealAPI.ts index c189231..86bd3b2 100644 --- a/src/services/recommendedMealAPI.ts +++ b/src/services/recommendedMealAPI.ts @@ -7,21 +7,34 @@ const formatDateToString = (date: Date): string => { const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; }; + /** * 식단 추천 API (임시 식단 기반) */ export const recommendedMealAPI = { /** * ✅ 1) 임시 식단 생성 + 조회 (7일치) + * @param mealsPerDay - 하루 끼니 수 (1~3, 기본값 3) */ - getWeeklyMealPlan: async () => { + getWeeklyMealPlan: async (mealsPerDay: number = 3) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 180000); // 3분 + try { - console.log("🍽️ 임시 식단 생성 시작"); + console.log("🍽️ 임시 식단 생성 시작 (7일치)"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + console.log("⏱️ 타임아웃: 3분"); - // Step 1: 임시 식단 생성 + // Step 1: 임시 식단 생성 (끼니 수 포함) const createResponse = await request<{ tempBundleId: string }>( "/api/temp-meals/weekly", - { method: "POST" } + { + method: "POST", + body: JSON.stringify({ mealsPerDay }), + signal: controller.signal, + } ); console.log("✅ 임시 식단 생성 완료:", createResponse); @@ -29,17 +42,80 @@ export const recommendedMealAPI = { // Step 2: 임시 식단 조회 const response = await request>( "/api/temp-meals/weekly", - { method: "GET" } + { + method: "GET", + signal: controller.signal, + } ); + clearTimeout(timeoutId); console.log("✅ 임시 식단 조회 완료:", response.length, "일"); return response; } catch (error: any) { + clearTimeout(timeoutId); + + if (error.name === "AbortError") { + console.error("⏱️ 타임아웃 발생 (3분 초과)"); + throw new Error( + "식단 생성 시간이 초과되었습니다.\n잠시 후 다시 시도해주세요." + ); + } + console.error("❌ 임시 식단 생성/조회 실패:", error); throw new Error(error.message || "식단 생성에 실패했습니다."); } }, + getDailyMealPlan: async (mealsPerDay: number = 3) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 120000); // 2분 + + try { + console.log("🍽️ 임시 식단 생성 시작 (1일치)"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + console.log("⏱️ 타임아웃: 2분"); + + // Step 1: 1일치 임시 식단 생성 (daily 엔드포인트 사용) + const createResponse = await request<{ + message?: string; + tempBundleId?: string; + }>("/api/temp-meals/daily", { + method: "POST", + body: JSON.stringify({ mealsPerDay }), + signal: controller.signal, + }); + + console.log("✅ 1일 임시 식단 생성 완료:", createResponse); + + // ✅ Step 2: 임시 식단 조회 + const response = await request>( + "/api/temp-meals/weekly", // ✅ daily가 아니라 weekly! + { + method: "GET", + signal: controller.signal, + } + ); + + clearTimeout(timeoutId); + console.log("✅ 임시 식단 조회 완료:", response.length, "일"); + return response; + } catch (error: any) { + clearTimeout(timeoutId); + + if (error.name === "AbortError") { + console.error("⏱️ 타임아웃 발생 (2분 초과)"); + throw new Error( + "식단 생성 시간이 초과되었습니다.\n잠시 후 다시 시도해주세요." + ); + } + + console.error("❌ 1일 식단 생성/조회 실패:", error); + throw new Error(error.message || "식단 생성에 실패했습니다."); + } + }, + /** * ✅ 2) 임시 식단 저장 (DB에 확정) */ @@ -130,7 +206,7 @@ export const recommendedMealAPI = { // BREAKFAST, LUNCH, DINNER가 없고 SNACK만 있는 경우 변환 if (!breakfast && !lunch && !dinner) { const snacks = meals.filter((m: any) => m.mealType === "SNACK"); - + if (snacks.length >= 1) { flattenedMeals.push({ ...snacks[0], @@ -315,7 +391,7 @@ export interface TempDayMeal { dayIndex: number; meals: Array<{ id: number; - mealType: "BREAKFAST" | "LUNCH" | "DINNER"; + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK"; totalCalories: number; totalCarbs: number; totalProtein: number; @@ -346,7 +422,7 @@ export interface Food { export interface Meal { id: number; - mealType: "BREAKFAST" | "LUNCH" | "DINNER"; + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK"; mealTypeName: string; totalCalories: number; totalCarbs: number;