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 (
+
+
+ {/* 중앙 텍스트 */}
+
+
+ {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": {