diff --git a/android/app/build.gradle b/android/app/build.gradle index 5298f4e..cb0b7d3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -51,7 +51,7 @@ android { // 로컬 기본값(수동 관리용) versionCode 15 - versionName "1.3.0" + versionName "1.2.2 buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" diff --git a/app.json b/app.json index 7a1b53a..d857a1a 100644 --- a/app.json +++ b/app.json @@ -10,7 +10,7 @@ "newArchEnabled": true, "ios": { "supportsTablet": false, - "buildNumber": "1.3.0", + "buildNumber": "10", "bundleIdentifier": "com.podostore.dasii", "infoPlist": { "NSUserTrackingUsageDescription": "맞춤형 콘텐츠/광고 및 앱 품질 개선을 위해 사용자 활동 데이터를 사용할 수 있도록 허용해 주세요.", @@ -80,6 +80,7 @@ ], "experiments": { "typedRoutes": true - } + }, + "assetBundlePatterns": ["**/*"] } } diff --git a/app/(tabs)/mypage/index.tsx b/app/(tabs)/mypage/index.tsx index ad87ec3..d6789b8 100644 --- a/app/(tabs)/mypage/index.tsx +++ b/app/(tabs)/mypage/index.tsx @@ -129,7 +129,7 @@ export default function Mypage() { /> diff --git a/app/product/[id]/productDetail.tsx b/app/product/[id]/productDetail.tsx index 74b459e..eacdf76 100644 --- a/app/product/[id]/productDetail.tsx +++ b/app/product/[id]/productDetail.tsx @@ -9,6 +9,7 @@ import { ReviewButton } from '@/components/common/buttons/ReviewButton'; import { ScrollToTopButton } from '@/components/common/buttons/ScrollToTopButton'; import DefaultModal from '@/components/common/modals/DefaultModal'; import Navigation from '@/components/layout/Navigation'; +// import AISummary from '@/components/page/product/productDetail/AISummary'; import IngredientInfoBottomSheet from '@/components/page/product/productDetail/BottomSheet/IngredientInfoBottomSheet'; import CoupangTabBar from '@/components/page/product/productDetail/CoupangTabBar'; import IngredientSection from '@/components/page/product/productDetail/Ingredient/IngredientSection'; @@ -37,8 +38,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, Image, Pressable, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -const fallbackPath = (id: string) => `/product/${id}`; - const tabs = [ { key: 'ingredient', label: '성분 정보' }, { key: 'review', label: '리뷰' }, @@ -239,11 +238,21 @@ export default function ProductDetail() { {/* 탭별 상단 콘텐츠 */} {activeTab === 'ingredient' ? ( - + + {/* */} + + ) : ( diff --git a/assets/icons/ic_ai_logo.svg b/assets/icons/ic_ai_logo.svg new file mode 100644 index 0000000..0631feb --- /dev/null +++ b/assets/icons/ic_ai_logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/page/home/BannerCarousel.tsx b/components/page/home/BannerCarousel.tsx index 3340a4e..afda853 100644 --- a/components/page/home/BannerCarousel.tsx +++ b/components/page/home/BannerCarousel.tsx @@ -23,6 +23,7 @@ export default function BannerCarousel({ data }: BannerCarouselProps) { index: number; }) => { const isFocused = activeIndex === index; + console.log('banner item image:', JSON.stringify(item.image)); // 추가 return ( + + + + AI 기반 제품 요약 + + + + + {summary.map((item, index) => ( + + + {item} + + ))} + + + + ); +} diff --git a/components/page/product/productDetail/Ingredient/OtherIngredients.tsx b/components/page/product/productDetail/Ingredient/OtherIngredients.tsx index 890e0d2..c8b9e38 100644 --- a/components/page/product/productDetail/Ingredient/OtherIngredients.tsx +++ b/components/page/product/productDetail/Ingredient/OtherIngredients.tsx @@ -1,5 +1,7 @@ // 기타 원료 +import ArrowRightIcon from '@/assets/icons/ic_arrow_right.svg'; import InfoIcon from '@/assets/icons/ic_info.svg'; +import { useRouter } from 'expo-router'; import { Pressable, Text, View } from 'react-native'; interface Props { @@ -7,10 +9,15 @@ interface Props { onPressInfo: () => void; } +// TODO: API 연결 필요 - 원료명으로 성분 ID를 조회하는 API가 필요합니다. +const MOCK_INGREDIENT_ID = '1'; + export default function OtherIngredientsSection({ ingredients, onPressInfo, }: Props) { + const router = useRouter(); + if (!ingredients || ingredients.length === 0) { return null; } @@ -30,11 +37,22 @@ export default function OtherIngredientsSection({ - + {ingredients.map((item, index) => ( - - • {item} - + + // TODO: API 연결 필요 - item(원료명)에 해당하는 실제 성분 ID로 교체해야 합니다. + router.push(`/ingredient/${MOCK_INGREDIENT_ID}`) + } + className='flex-row items-center justify-between py-[10px]' + > + + + {item} + + + ))} diff --git a/components/page/product/productDetail/materialInfo.tsx b/components/page/product/productDetail/materialInfo.tsx index e3b677e..bfa3ea1 100644 --- a/components/page/product/productDetail/materialInfo.tsx +++ b/components/page/product/productDetail/materialInfo.tsx @@ -1,11 +1,195 @@ +import ArrowRightIcon from '@/assets/icons/ic_arrow_right.svg'; import EffectIfon from '@/assets/icons/ic_effect.svg'; import IngredienStatusIcon from '@/assets/icons/ic_ingredien_status.svg'; import DownArrowIcon from '@/assets/icons/product/productDetail/ic_arrow_down.svg'; import UpArrowIcon from '@/assets/icons/product/productDetail/ic_arrow_up.svg'; import { ProductIngredient } from '@/services/product/getProductDetail'; +import { useRouter } from 'expo-router'; import { useState } from 'react'; import { Pressable, Text, View } from 'react-native'; -import ProgressBar from './progressBar'; +import Svg, { + Circle, + Defs, + Stop, + LinearGradient as SvgGradient, +} from 'react-native-svg'; + +// ─── Donut chart ───────────────────────────────────────────────────────────── + +const DONUT_SIZE = 74; +const CENTER = DONUT_SIZE / 2; +const RADIUS = 30; +const STROKE_WIDTH = 11; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +const STATUS_COLORS: Record = { + 초과: { start: '#FDDF02', end: '#FF0000' }, + 적정: { start: '#FDDF02', end: '#9DD716' }, + 미만: { start: '#FDDF02', end: '#FFA600' }, +}; + +function parseNumeric(s: string) { + return parseFloat(s.replace(/[^0-9.]/g, '')) || 0; +} + +function formatNumber(s: string): string { + return parseNumeric(s).toLocaleString('ko-KR'); +} + +/** 단위를 μg 기준으로 정규화 */ +function toMicrograms(s: string): number { + const value = parseNumeric(s); + const unit = (s.match(/[a-zA-Zμ]+/)?.[0] ?? '').toLowerCase(); + if (unit === 'g') return value * 1_000_000; + if (unit === 'mg') return value * 1_000; + if (unit === 'μg' || unit === 'mcg' || unit === 'ug') return value; + // 단위 없거나 알 수 없는 경우 그대로 비교 + return value; +} + +/** 백엔드가 단위 미고려로 잘못 계산할 수 있으므로 프론트에서 재계산 */ +function computeStatus( + amount: string, + minRecommended: string, + maxRecommended: string, +): string { + const a = toMicrograms(amount); + const min = toMicrograms(minRecommended); + const max = toMicrograms(maxRecommended); + if (a > max) return '초과'; + if (a < min) return '미만'; + return '적정'; +} + +function DonutChart({ + amount, + maxRecommended, + status, +}: { + amount: string; + maxRecommended: string; + status: string; +}) { + const fillRatio = + status === '초과' + ? 1 + : Math.min(toMicrograms(amount) / (toMicrograms(maxRecommended) || 1), 1); + const colors = STATUS_COLORS[status] ?? STATUS_COLORS['미만']; + const dashOffset = CIRCUMFERENCE * (1 - fillRatio); + const amountTextColor = + status === '초과' ? '#FF3A4A' : status === '미만' ? '#FFA600' : '#25A762'; + + return ( + + + + {/* + * Circle에 rotate(-90, CENTER, CENTER) transform이 있으므로 + * userSpaceOnUse 좌표는 그 회전된 좌표계 기준임. + * 역변환(+90) 적용 결과: + * (DONUT_SIZE, DONUT_SIZE) → 스크린 Q1(우상단) = 노란색 + * (0, 0) → 스크린 Q3(좌하단) = 상태색 + */} + + + + + + {/* 배경 트랙 */} + + {/* 채워지는 호 */} + + + {/* 중앙 텍스트 */} + + + {formatNumber(amount)} + + + + /{formatNumber(maxRecommended)} + + + + ); +} + +// ─── Speech bubble ──────────────────────────────────────────────────────────── + +function SpeechBubble() { + return ( + + + 주성분 포함량 + + {/* 아래 삼각형 */} + + + ); +} + +// ─── Status tag ─────────────────────────────────────────────────────────────── + +function StatusTag({ status }: { status: string }) { + const bgColor = + status === '초과' + ? 'bg-[#FF3A4A]' + : status === '미만' + ? 'bg-[#FFA600]' + : 'bg-green-600'; + return ( + + + {status} + + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── export default function MaterialInfo({ materialInfo, @@ -13,8 +197,12 @@ export default function MaterialInfo({ materialInfo: ProductIngredient; }) { const [isOpen, setIsOpen] = useState(false); - - console.log('materialInfo', materialInfo); + const router = useRouter(); + const status = computeStatus( + materialInfo.amount, + materialInfo.minRecommended, + materialInfo.maxRecommended, + ); const renderBulletList = (items: string[]) => { if (!items || items.length === 0) { @@ -24,11 +212,10 @@ export default function MaterialInfo({ ); } - return ( {items.map((txt, idx) => ( - + {txt} @@ -39,86 +226,118 @@ export default function MaterialInfo({ return ( <> - - - - + {/* ── 카드 ─────────────────────────────────────── */} + + {/* 좌측 콘텐츠 */} + + {/* 제목 */} + + // TODO: API 연결 필요 - ingredientName으로 성분 ID를 조회하는 API가 필요합니다. + router.push('/ingredient/1') + } + > + {materialInfo.ingredientName} - + + + {/* 주성분 부제목 */} + {materialInfo.mainIngredient ? ( + - - {materialInfo.status} - + (주성분 : {materialInfo.mainIngredient}) + + ) : null} + + {/* 포함량 행 */} + + + 포함량 + + {materialInfo.amount} + + - {materialInfo.mainIngredient && ( - - (주성분 : {materialInfo.mainIngredient}) + + {/* 적정 섭취량 행 */} + + + + 적정 섭취량 + + + + + {materialInfo.minRecommended}~{materialInfo.maxRecommended} - )} - - - 포함량 {materialInfo.amount} - - - 1일 권장량 {materialInfo.minRecommended}~ - {materialInfo.maxRecommended} - - + + + {/* 세로 구분선 */} + + + {/* 우측: 도넛 */} + + + + + + - {isOpen ? ( - - setIsOpen(false)} - className='flex-row justify-between items-center h-[40px]' - > - - 효과 및 부작용 알아보기 - - - - - - - - - {renderBulletList(materialInfo.effect ?? [])} - - - - - - {renderBulletList(materialInfo.sideEffect ?? [])} - - + {/* ── 효과 및 부작용 알아보기 (기존 유지) ─────── */} + {isOpen ? ( + + setIsOpen(false)} + className='flex-row justify-between items-center h-[40px]' + > + 효과 및 부작용 알아보기 + + - ) : ( - setIsOpen(true)} - className='bg-[#F6F5FA] rounded-[12px] flex-row justify-between items-center px-3 h-[40px] ' - > - 효과 및 부작용 알아보기 - - - - - )} + + + + + {renderBulletList(materialInfo.effect ?? [])} + + + + + {renderBulletList(materialInfo.sideEffect ?? [])} + + - - + ) : ( + setIsOpen(true)} + className='bg-[#F6F5FA] rounded-[12px] flex-row justify-between items-center px-3 h-[40px] mb-5' + > + 효과 및 부작용 알아보기 + + + + + )} ); } diff --git a/ios/.xcode.env b/ios/.xcode.env index 3d5782c..34d2a70 100644 --- a/ios/.xcode.env +++ b/ios/.xcode.env @@ -1,11 +1 @@ -# This `.xcode.env` file is versioned and is used to source the environment -# used when running script phases inside Xcode. -# To customize your local environment, you can create an `.xcode.env.local` -# file that is not versioned. - -# NODE_BINARY variable contains the PATH to the node executable. -# -# Customize the NODE_BINARY variable here. -# For example, to use nvm with brew, add the following line -# . "$(brew --prefix nvm)/nvm.sh" --no-use -export NODE_BINARY=$(command -v node) +export NODE_BINARY=/opt/homebrew/bin/node diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index f25a9af..46918cd 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 69BCC5B9F278BB3D48F606B3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AD7846694165E99D2FFD2483 /* PrivacyInfo.xcprivacy */; }; 8F7C38E32F3EC73000EA1568 /* main.jsbundle in Resources */ = {isa = PBXBuildFile; fileRef = 8F7C38E22F3EC73000EA1568 /* main.jsbundle */; }; + 8FD695EF2F6AF99C0094520F /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 8FD695EE2F6AF99C0094520F /* assets */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; C7DD18F8227AB3B3CAC70AF7 /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D5F1793DFDFC4DEC99E02083 /* libPods-app.a */; }; C99AE5CCAA98E6181FE51957 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C572DCD298091D399BDA26 /* ExpoModulesProvider.swift */; }; @@ -24,6 +25,7 @@ 19FE6FD92EFF4033001EB8AE /* appRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = appRelease.entitlements; path = app/appRelease.entitlements; sourceTree = ""; }; 3DCAA8FD72613BEF3FDB6DF5 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = ""; }; 8F7C38E22F3EC73000EA1568 /* main.jsbundle */ = {isa = PBXFileReference; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 8FD695EE2F6AF99C0094520F /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = app/SplashScreen.storyboard; sourceTree = ""; }; AD7846694165E99D2FFD2483 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = app/PrivacyInfo.xcprivacy; sourceTree = ""; }; BB0D5616218D03AEB8929AE0 /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = ""; }; @@ -106,6 +108,7 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 8FD695EE2F6AF99C0094520F /* assets */, 8F7C38E22F3EC73000EA1568 /* main.jsbundle */, 13B07FAE1A68108700A75B9A /* app */, 832341AE1AAA6A7D00B99B32 /* Libraries */, @@ -198,6 +201,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8FD695EF2F6AF99C0094520F /* assets in Resources */, 8F7C38E32F3EC73000EA1568 /* main.jsbundle in Resources */, BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, @@ -376,7 +380,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = AGQJU3FNWG; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -413,7 +417,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/appRelease.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = AGQJU3FNWG; INFOPLIST_FILE = app/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; diff --git a/ios/app/AppDelegate.swift b/ios/app/AppDelegate.swift index d945ee4..33bdf01 100644 --- a/ios/app/AppDelegate.swift +++ b/ios/app/AppDelegate.swift @@ -5,68 +5,68 @@ import ReactAppDependencyProvider @UIApplicationMain public class AppDelegate: ExpoAppDelegate { - var window: UIWindow? +var window: UIWindow? - var reactNativeDelegate: ExpoReactNativeFactoryDelegate? - var reactNativeFactory: RCTReactNativeFactory? +var reactNativeDelegate: ExpoReactNativeFactoryDelegate? +var reactNativeFactory: RCTReactNativeFactory? - public override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - let delegate = ReactNativeDelegate() - let factory = ExpoReactNativeFactory(delegate: delegate) - delegate.dependencyProvider = RCTAppDependencyProvider() +public override func application( +_ application: UIApplication, +didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil +) -> Bool { +let delegate = ReactNativeDelegate() +let factory = ExpoReactNativeFactory(delegate: delegate) +delegate.dependencyProvider = RCTAppDependencyProvider() - reactNativeDelegate = delegate - reactNativeFactory = factory - bindReactNativeFactory(factory) +reactNativeDelegate = delegate +reactNativeFactory = factory +bindReactNativeFactory(factory) #if os(iOS) || os(tvOS) - window = UIWindow(frame: UIScreen.main.bounds) - factory.startReactNative( - withModuleName: "main", - in: window, - launchOptions: launchOptions) +window = UIWindow(frame: UIScreen.main.bounds) +factory.startReactNative( +withModuleName: "main", +in: window, +launchOptions: launchOptions) #endif - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } +return super.application(application, didFinishLaunchingWithOptions: launchOptions) +} - // Linking API - public override func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - if(RNCKakaoUserUtil.isKakaoTalkLoginUrl(url)) { return RNCKakaoUserUtil.handleOpen(url) } - return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) - } +// Linking API +public override func application( +_ app: UIApplication, +open url: URL, +options: [UIApplication.OpenURLOptionsKey: Any] = [:] +) -> Bool { +if(RNCKakaoUserUtil.isKakaoTalkLoginUrl(url)) { return RNCKakaoUserUtil.handleOpen(url) } +return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) +} - // Universal Links - public override func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) - return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result - } +// Universal Links +public override func application( +_ application: UIApplication, +continue userActivity: NSUserActivity, +restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void +) -> Bool { +let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) +return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result +} } class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { - // Extension point for config-plugins +// Extension point for config-plugins - override func sourceURL(for bridge: RCTBridge) -> URL? { - // needed to return the correct URL for expo-dev-client. - bridge.bundleURL ?? bundleURL() - } +override func sourceURL(for bridge: RCTBridge) -> URL? { +// needed to return the correct URL for expo-dev-client. +bridge.bundleURL ?? bundleURL() +} - override func bundleURL() -> URL? { +override func bundleURL() -> URL? { #if DEBUG - return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") #else - return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +return Bundle.main.url(forResource: "main", withExtension: "jsbundle") #endif - } +} } diff --git a/package.json b/package.json index 6d341e4..d3bf547 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dasii", - "version": "1.3.0", + "version": "1.2.2", "private": true, "main": "expo-router/entry", "scripts": {