+
+
+
= {
avobenzone: "아보벤존",
@@ -25,76 +27,89 @@ const INGREDIENT_KO_MAP: Record = {
};
export const CosmeticComponent = () => {
- const [dangerIngredients, setDangerIngredients] = useState([]);
- const [allergyIngredients, setAllergyIngredients] = useState([]);
- const [dangerOpen, setDangerOpen] = useState(true);
- const [allergyOpen, setAllergyOpen] = useState(false);
+ const [detectedIngredients, setDetectedIngredients] = useState(
+ [],
+ );
const [isLoading, setIsLoading] = useState(true);
const commonTextStyle: React.CSSProperties = {
fontFamily: "KoddiUD OnGothic",
fontSize: "28px",
- fontStyle: "normal",
+ fontWeight: 700,
+ lineHeight: "150%",
+ textAlign: "left",
+ };
+ const commonTextStyle24: React.CSSProperties = {
+ fontFamily: "KoddiUD OnGothic",
+ fontSize: "24px",
fontWeight: 700,
lineHeight: "150%",
textAlign: "left",
};
useEffect(() => {
- const fetchData = async (vendorEl: Element) => {
+ const fetchData = async () => {
try {
- const productId =
- window.location.href.match(/products\/(\d+)/)?.[1];
- if (!productId) {
- return;
- }
-
- const rawHtml = vendorEl.outerHTML
- .replace(/\sonerror=\"[^\"]*\"/g, "")
- .replace(/\n/g, "")
- .trim();
-
- if (!rawHtml.includes("
{
- const data = res?.data || {};
- const allList = Object.entries(data)
- .filter(([_, v]) => v === true)
- .map(([k]) => INGREDIENT_KO_MAP[k] || k);
-
- setDangerIngredients(allList.slice(0, 20));
- setAllergyIngredients(allList.slice(20));
- setIsLoading(false);
- },
+ const response = await new Promise<{
+ html: string;
+ productId: string;
+ }>((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ { type: "FETCH_VENDOR_HTML" },
+ (res) => {
+ if (
+ chrome.runtime.lastError ||
+ !res?.html ||
+ !res?.productId ||
+ res.html.trim() === ""
+ ) {
+ let retries = 10;
+ const interval = setInterval(() => {
+ chrome.runtime.sendMessage(
+ { type: "FETCH_VENDOR_HTML" },
+ (retryRes) => {
+ if (
+ retryRes?.html?.trim() &&
+ retryRes?.productId
+ ) {
+ clearInterval(interval);
+ resolve(retryRes);
+ } else if (--retries === 0) {
+ clearInterval(interval);
+ reject(
+ new Error(
+ "HTML 또는 productId 누락",
+ ),
+ );
+ }
+ },
+ );
+ }, 500);
+ } else {
+ resolve(res);
+ }
+ },
+ );
+ });
+
+ const { html, productId } = response;
+ const detectedKeys = await sendCosmeticDataRequest({
+ productId,
+ html,
+ });
+ const detected = detectedKeys.map(
+ (key) => INGREDIENT_KO_MAP[key] || key,
);
- } catch (e) {
+
+ setDetectedIngredients(detected);
+ setIsLoading(false);
+ } catch (err) {
+ console.error("[voim] 화장품 성분 분석 실패:", err);
setIsLoading(false);
}
};
- const vendorEl = document.querySelector(".vendor-item");
-
- if (vendorEl) {
- fetchData(vendorEl);
- } else {
- const observer = new MutationObserver(() => {
- const target = document.querySelector(".vendor-item");
- if (target) {
- observer.disconnect();
- fetchData(target);
- }
- });
-
- observer.observe(document.body, { childList: true, subtree: true });
- return () => observer.disconnect();
- }
+ fetchData();
}, []);
if (isLoading) {
@@ -102,21 +117,25 @@ export const CosmeticComponent = () => {
- 화장품 성분을 분석 중입니다...
+
+
+
+
+ 제품 정보를 분석 중입니다.
+
);
}
@@ -125,21 +144,11 @@ export const CosmeticComponent = () => {
[화장품] 성분 안내
-
-
-
{
...commonTextStyle,
}}
>
- 20가지 주의 성분
- 총 {dangerIngredients.length}개
-
-
- {dangerOpen && (
-
- {dangerIngredients.length > 0 ? (
- dangerIngredients.map((item, idx) => (
+ {detectedIngredients.length > 0 && (
+
+ {detectedIngredients.map((item, idx) => (
{item}
- ))
- ) : (
-
- 표시할 주의 성분이 없습니다.
-
- )}
-
- )}
-
-
-
-
- 알레르기 유발 성분
- 총 {allergyIngredients.length}개
+ ))}
+
+ )}
-
- {allergyOpen && (
-
- {allergyIngredients.length > 0 ? (
- allergyIngredients.map((item, idx) => (
-
- {item}
-
- ))
- ) : (
-
- 표시할 알레르기 유발 성분이 없습니다.
-
- )}
-
- )}
-
-
);
};
diff --git a/src/components/productComponents/foodComponent.tsx b/src/components/productComponents/foodComponent.tsx
index 4afef36..8c4af75 100644
--- a/src/components/productComponents/foodComponent.tsx
+++ b/src/components/productComponents/foodComponent.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { sendFoodDataRequest } from "../../content/apiSetting/sendFoodDataRequest";
+import Loading from "../Loading/component";
interface Nutrient {
nutrientType: string;
@@ -21,77 +22,157 @@ const nutrientNameMap: Record = {
VITAMIN_B: "비타민 B",
VITAMIN_E: "비타민 E",
};
+const allergyNameMap: Record = {
+ EGG: "계란",
+ MILK: "우유",
+ BUCKWHEAT: "메밀",
+ PEANUT: "땅콩",
+ SOYBEAN: "대두",
+ WHEAT: "밀",
+ PINE_NUT: "잣",
+ WALNUT: "호두",
+ CRAB: "게",
+ SHRIMP: "새우",
+ SQUID: "오징어",
+ MACKEREL: "고등어",
+ SHELLFISH: "조개류",
+ PEACH: "복숭아",
+ TOMATO: "토마토",
+ CHICKEN: "닭고기",
+ PORK: "돼지고기",
+ BEEF: "쇠고기",
+ SULFITE: "아황산류",
+};
export const FoodComponent = () => {
+ console.log("FoodComponent 등장");
const [nutrientAlerts, setNutrientAlerts] = useState(
null,
);
const [allergyTypes, setAllergyTypes] = useState(null);
const [nutrientOpen, setNutrientOpen] = useState(true);
- const [allergyOpen, setAllergyOpen] = useState(false);
+ const [allergyOpen, setAllergyOpen] = useState(true);
const commonTextStyle: React.CSSProperties = {
fontFamily: "KoddiUD OnGothic",
fontSize: "28px",
- fontStyle: "normal",
fontWeight: 700,
lineHeight: "150%",
textAlign: "left",
};
+ const commonTextStyle24: React.CSSProperties = {
+ fontFamily: "KoddiUD OnGothic",
+ fontSize: "24px",
+ fontWeight: 700,
+ lineHeight: "150%",
+ textAlign: "left",
+ };
+ const getProductTitle = (): Promise => {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage({ type: "GET_PRODUCT_TITLE" }, (res) => {
+ if (chrome.runtime.lastError || !res?.title) {
+ console.warn("[voim][FoodComponent] title 가져오기 실패");
+ return resolve("");
+ }
+ resolve(res.title);
+ });
+ });
+ };
useEffect(() => {
- const fetchData = async (vendorEl: Element) => {
+ const fetchData = async () => {
try {
- const { birthYear, gender } = await chrome.storage.local.get([
- "birthYear",
- "gender",
- ]);
+ const { birthYear, gender, Allergies } =
+ await chrome.storage.local.get([
+ "birthYear",
+ "gender",
+ "Allergies",
+ ]);
- const productId =
- window.location.href.match(/products\/(\d+)/)?.[1];
- if (!birthYear || !gender || !productId) return;
+ console.debug("[voim] 스토리지에서 가져온 값:", {
+ birthYear,
+ gender,
+ Allergies,
+ });
- const rawHtml = vendorEl.outerHTML
- .replace(/\sonerror=\"[^\"]*\"/g, "")
- .replace(/\n/g, "")
- .trim();
+ if (!birthYear || !gender) return;
+ const title = await getProductTitle();
+ const response = await new Promise<{
+ html: string;
+ productId: string;
+ }>((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ { type: "FETCH_VENDOR_HTML" },
+ (res) => {
+ if (
+ chrome.runtime.lastError ||
+ !res?.html ||
+ !res?.productId ||
+ res.html.trim() === ""
+ ) {
+ console.warn(
+ "[voim] FETCH_VENDOR_HTML 응답 없음, 대기 중...",
+ );
+ let retries = 10;
+ const interval = setInterval(() => {
+ chrome.runtime.sendMessage(
+ { type: "FETCH_VENDOR_HTML" },
+ (retryRes) => {
+ if (
+ retryRes?.html?.trim() &&
+ retryRes?.productId
+ ) {
+ console.log(
+ "[voim] FETCH_VENDOR_HTML 재시도 성공:",
+ retryRes,
+ );
+ clearInterval(interval);
+ resolve(retryRes);
+ } else if (--retries === 0) {
+ clearInterval(interval);
+ reject(
+ new Error(
+ "HTML 또는 productId 누락",
+ ),
+ );
+ }
+ },
+ );
+ }, 500);
+ } else {
+ console.log(
+ "[voim] FETCH_VENDOR_HTML 성공 응답:",
+ res,
+ );
+ resolve(res);
+ }
+ },
+ );
+ });
const payload = {
- productId,
- title: document.title,
- html: rawHtml,
+ productId: response.productId,
+ title: title,
+ html: response.html,
birthYear: Number(birthYear),
gender: gender.toUpperCase(),
- allergies: [],
+ allergies: Allergies || [],
};
- const res = await sendFoodDataRequest(payload);
- if (!res) throw new Error("응답 없음");
+ console.log("[voim] FOOD API 요청 payload:", payload);
+
+ const result = await sendFoodDataRequest(payload);
- setNutrientAlerts(res.overRecommendationNutrients || []);
- setAllergyTypes(res.allergyTypes || []);
+ console.log("[voim] FOOD API 응답:", result);
+
+ setNutrientAlerts(result.overRecommendationNutrients || []);
+ setAllergyTypes(result.allergyTypes || []);
} catch (e) {
console.error("[voim] FOOD API 실패:", e);
}
};
- const vendorEl = document.querySelector(".vendor-item");
- if (vendorEl) {
- fetchData(vendorEl);
- return;
- }
-
- const observer = new MutationObserver(() => {
- const foundEl = document.querySelector(".vendor-item");
- if (foundEl) {
- observer.disconnect();
- fetchData(foundEl);
- }
- });
-
- observer.observe(document.body, { childList: true, subtree: true });
-
- return () => observer.disconnect();
+ fetchData();
}, []);
useEffect(() => {
@@ -102,7 +183,6 @@ export const FoodComponent = () => {
setAllergyTypes([]);
}
}, 10000);
-
return () => clearTimeout(timeout);
}, [nutrientAlerts, allergyTypes]);
@@ -111,17 +191,25 @@ export const FoodComponent = () => {
- 제품 정보를 분석 중입니다...
+
+
+
+
+ 제품 정보를 분석 중입니다.
+
);
}
@@ -130,23 +218,12 @@ export const FoodComponent = () => {
-
- [식품] 영양 및 알레르기 유발 식재료 안내
-
-
-
-
+
식품 영양 및 알러지 성분
+
{
하루 기준 섭취량의 40% 넘는 영양성분
총 {nutrientAlerts.length}개
-
- {nutrientOpen && (
+ {nutrientOpen && nutrientAlerts.length > 0 && (
{nutrientAlerts.map((item, idx) => (
@@ -174,10 +250,7 @@ export const FoodComponent = () => {
style={{
display: "flex",
justifyContent: "space-between",
- fontSize: "24px",
- fontStyle: "normal",
- fontWeight: 700,
- fontFamily: "KoddiUDOnGothic",
+ ...commonTextStyle24,
marginBottom:
idx < nutrientAlerts.length - 1
? "12px"
@@ -194,24 +267,6 @@ export const FoodComponent = () => {
)}
-
-
{
알레르기 유발 성분
총 {allergyTypes.length}개
-
- {allergyOpen && (
+ {allergyOpen && allergyTypes.length > 0 && (
{allergyTypes.map((item, idx) => (
- {item}
+ {allergyNameMap[item] || item}
))}
)}
-
-
);
};
diff --git a/src/components/productComponents/healthComponent.tsx b/src/components/productComponents/healthComponent.tsx
index 93b7854..8d9634e 100644
--- a/src/components/productComponents/healthComponent.tsx
+++ b/src/components/productComponents/healthComponent.tsx
@@ -1,179 +1,213 @@
import React, { useEffect, useState } from "react";
+import { sendHealthDataRequest } from "../../content/apiSetting/sendHealthDataRequest";
+import Loading from "../Loading/component";
+
+const healthEffectMap: Record = {
+ IMMUNE: "면역기능",
+ SKIN: "피부건강",
+ BLOOD: "혈액건강",
+ BODY_FAT: "체지방 감소",
+ BLOOD_SUGAR: "혈당조절",
+ MEMORY: "기억력",
+ ANTIOXIDANT: "항산화",
+ GUT: "장건강",
+ LIVER: "간건강",
+ EYE: "눈건강",
+ JOINT: "관절건강",
+ SLEEP: "수면건강",
+ STRESS_FATIGUE: "스트레스/피로개선",
+ MENOPAUSE: "갱년기건강",
+ PROSTATE: "전립선건강",
+ URINARY: "요로건강",
+ ENERGY: "에너지대사",
+ BONE: "뼈건강",
+ MUSCLE: "근력/운동수행능력",
+ COGNITION: "인지기능",
+ STOMACH: "위건강",
+ ORAL: "구강건강",
+ HAIR: "모발건강",
+ GROWTH: "어린이 성장",
+ BLOOD_PRESSURE: "혈압",
+ URINATION: "배뇨건강",
+ FOLATE: "엽산대사",
+ NOSE: "코건강",
+ MALE_HEALTH: "남성건강",
+ ELECTROLYTE: "전해질 균형",
+ DIETARY_FIBER: "식이섬유",
+ ESSENTIAL_FATTY_ACID: "필수지방산",
+};
export const HealthComponent = () => {
- const [healthEffects, setHealthEffects] = useState(null);
- const [showAll, setShowAll] = useState(false);
+ const [healthTypes, setHealthTypes] = useState(null);
const commonTextStyle: React.CSSProperties = {
fontFamily: "KoddiUD OnGothic",
fontSize: "28px",
- fontStyle: "normal",
+ fontWeight: 700,
+ lineHeight: "150%",
+ textAlign: "left",
+ };
+ const commonTextStyle24: React.CSSProperties = {
+ fontFamily: "KoddiUD OnGothic",
+ fontSize: "24px",
fontWeight: 700,
lineHeight: "150%",
textAlign: "left",
};
+ const getProductTitle = (): Promise => {
+ return new Promise((resolve) => {
+ chrome.runtime.sendMessage({ type: "GET_PRODUCT_TITLE" }, (res) => {
+ if (chrome.runtime.lastError || !res?.title) {
+ console.warn("[voim][HealthComponent] title 가져오기 실패");
+ return resolve("");
+ }
+ resolve(res.title);
+ });
+ });
+ };
+
useEffect(() => {
- const fetchData = async (targetEl: Element) => {
- const productId =
- window.location.href.match(/products\/(\d+)/)?.[1];
- if (!productId) {
- return;
- }
+ const fetchData = async () => {
+ try {
+ const { birthYear, gender, Allergies } =
+ await chrome.storage.local.get([
+ "birthYear",
+ "gender",
+ "Allergies",
+ ]);
- const { birthYear, gender } = await chrome.storage.local.get([
- "birthYear",
- "gender",
- ]);
+ if (!birthYear || !gender) return;
- const rawHtml = targetEl.outerHTML
- .replace(/\sonerror=\"[^\"]*\"/g, "")
- .replace(/\n/g, "")
- .trim();
+ const title = await getProductTitle();
+ const response = await new Promise<{
+ html: string;
+ productId: string;
+ }>((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ { type: "FETCH_VENDOR_HTML" },
+ (res) => {
+ if (!res?.html?.trim() || !res?.productId) {
+ let retries = 10;
+ const interval = setInterval(() => {
+ chrome.runtime.sendMessage(
+ { type: "FETCH_VENDOR_HTML" },
+ (retryRes) => {
+ if (
+ retryRes?.html?.trim() &&
+ retryRes?.productId
+ ) {
+ clearInterval(interval);
+ resolve(retryRes);
+ } else if (--retries === 0) {
+ clearInterval(interval);
+ reject(
+ new Error(
+ "HTML 또는 productId 누락",
+ ),
+ );
+ }
+ },
+ );
+ }, 500);
+ } else {
+ resolve(res);
+ }
+ },
+ );
+ });
- chrome.runtime.sendMessage(
- {
- type: "FETCH_HEALTH_DATA",
- payload: {
- productId,
- title: document.title,
- html: rawHtml,
- birthYear: Number(birthYear),
- gender: gender?.toUpperCase() || "UNKNOWN",
- allergies: [],
- },
- },
- (res) => {
- const data = res?.data?.types || [];
- setHealthEffects(data);
- if (res?.data?.types) {
- setHealthEffects(res.data.types);
- }
- },
- );
- };
+ const payload = {
+ productId: response.productId,
+ title,
+ html: response.html,
+ birthYear: Number(birthYear),
+ gender: gender.toUpperCase(),
+ allergies: Allergies || [],
+ };
- const targetEl =
- document.querySelector(".vendor-item") ||
- document.querySelector(".product-detail-content") ||
- document.querySelector(".prod-image");
+ console.log("[voim] HEALTH API 요청 payload:", payload);
- if (targetEl) {
- fetchData(targetEl);
- } else {
- const observer = new MutationObserver(() => {
- const el =
- document.querySelector(".vendor-item") ||
- document.querySelector(".product-detail-content") ||
- document.querySelector(".prod-image");
- if (el) {
- observer.disconnect();
- fetchData(el);
- }
- });
- observer.observe(document.body, { childList: true, subtree: true });
- return () => observer.disconnect();
- }
+ const result = await sendHealthDataRequest(payload);
+ console.log("[voim] HEALTH API 응답:", result);
+ setHealthTypes(result || []);
+ } catch (e) {
+ console.error("[voim] HEALTH API 실패:", e);
+ }
+ };
+
+ fetchData();
}, []);
- if (!healthEffects) {
+ if (healthTypes === null) {
return (
- 제품 효능을 분석 중입니다...
+
+
+
+
+ 제품 정보를 분석 중입니다.
+
);
}
- const visibleItems = showAll ? healthEffects : healthEffects.slice(0, 3);
-
return (
-
[건강기능식품] 제품 효능
-
- 아래의 {healthEffects.length}가지 기능성 효능을 가진 제품입니다.
-
- 섭취 시 참고해주세요.
+
+ 해당 제품의 기능성 효능 {healthTypes.length}가지
-
-
- {visibleItems.map((item, idx) => (
-
- {item}
-
- ))}
-
-
+
+ {healthTypes.length > 0 && (
+
+ {healthTypes.map((item, idx) => (
+
+ {healthEffectMap[item] || item}
+
+ ))}
+
+ )}
);
};
diff --git a/src/components/productComponents/infoComponent.tsx b/src/components/productComponents/infoComponent.tsx
index 5fa29a6..9cc1d3a 100644
--- a/src/components/productComponents/infoComponent.tsx
+++ b/src/components/productComponents/infoComponent.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { sendOutlineInfoRequest } from "../../content/apiSetting/sendInfoRequest";
import { BaseFillButton } from "../baseFillButton/component";
import { BaseButton } from "../baseButton/component";
@@ -6,34 +6,42 @@ import { useTheme } from "@src/contexts/ThemeContext";
type OutlineCategory = "MAIN" | "USAGE" | "WARNING" | "SPECS" | "CERTIFICATION";
-const OUTLINE_CATEGORIES = [
- { key: "MAIN", label: "주요 성분" },
- { key: "USAGE", label: "사용 방법 및 대상" },
- { key: "WARNING", label: "주의 및 보관" },
- { key: "SPECS", label: "규격 및 옵션" },
- { key: "CERTIFICATION", label: "인증 및 기타" },
-] as const;
+interface InfoComponentProps {
+ categoryType: "food" | "cosmetic" | "health" | "none" | null;
+}
-export const InfoComponent = () => {
+export const InfoComponent: React.FC = ({
+ categoryType,
+}) => {
const { theme, fontClasses } = useTheme();
const isDarkMode = theme === "dark";
const [selected, setSelected] = useState(null);
const [info, setInfo] = useState("");
const [loading, setLoading] = useState(false);
+ const [mainLabel, setMainLabel] = useState("스펙 및 제조 정보");
+
+ useEffect(() => {
+ if (categoryType === "food" || categoryType === "health") {
+ setMainLabel("영양 및 원재료");
+ } else {
+ setMainLabel("스펙 및 제조 정보");
+ }
+ }, [categoryType]);
+
+ const outlineCategories = [
+ { key: "MAIN", label: mainLabel },
+ { key: "USAGE", label: "사용 방법 및 대상" },
+ { key: "WARNING", label: "주의 및 보관" },
+ { key: "SPECS", label: "구성 및 디자인" },
+ ] as const;
const fetchVendorHtml = (): Promise => {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
- {
- type: "FETCH_VENDOR_HTML",
- },
+ { type: "FETCH_VENDOR_HTML" },
(response) => {
if (chrome.runtime.lastError) {
- console.error(
- "HTML 요청 실패:",
- chrome.runtime.lastError.message,
- );
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response?.html ?? "");
@@ -66,7 +74,7 @@ export const InfoComponent = () => {
return (
- {OUTLINE_CATEGORIES.map(({ key, label }) => (
+ {outlineCategories.map(({ key, label }) => (
{selected === key ? (
@@ -84,17 +92,24 @@ export const InfoComponent = () => {
"불러오는 중..."
) : (
- {info.split("\n").map(
- (item, index) =>
- item.trim() && (
- -
- {item.trim()}
-
- ),
- )}
+ {info
+ .split("\n")
+ .map((item) =>
+ item.replace(/^-/, "").trim(),
+ )
+ .filter(Boolean)
+ .map((item, index) => (
+ - (.*?)<\/strong>/g,
+ `$1`,
+ ),
+ }}
+ />
+ ))}
)}
diff --git a/src/components/sidebar/component.tsx b/src/components/sidebar/component.tsx
index 5894e60..005adc0 100644
--- a/src/components/sidebar/component.tsx
+++ b/src/components/sidebar/component.tsx
@@ -2,11 +2,11 @@ import React, { useState, useEffect, useRef } from "react";
import { useTheme } from "@src/contexts/ThemeContext";
import { CloseButton } from "../closeButton/component";
import { InfoComponent } from "@src/components/productComponents/infoComponent";
-import { observeBreadcrumbFoodAndRender } from "@src/content/coupang/categoryHandler/categoryHandlerFood";
-import { observeBreadcrumbCosmeticAndRender } from "@src/content/coupang/categoryHandler/categoryHandlerCosmetic";
import { HealthComponent } from "@src/components/productComponents/healthComponent";
import { ReviewSummaryComponent } from "../productComponents/ReviewSummaryComponent";
import CartSummaryComponent from "../productComponents/CartSummaryComponent";
+import { FoodComponent } from "../productComponents/foodComponent";
+import { CosmeticComponent } from "../productComponents/cosmeticComponent";
interface ModalProps {
isOpen: boolean;
@@ -59,27 +59,6 @@ export function Sidebar({
: "detail",
);
}, [type, isCartPage]);
-
- const foodMountRef = useRef
(null);
- const cosmeticMountRef = useRef(null);
-
- useEffect(() => {
- if (
- isOpen &&
- selectedTab === "ingredient" &&
- type &&
- type !== "none" &&
- !isCartPage
- ) {
- if (type === "food" && foodMountRef.current) {
- observeBreadcrumbFoodAndRender(foodMountRef.current);
- }
- if (type === "cosmetic" && cosmeticMountRef.current) {
- observeBreadcrumbCosmeticAndRender(cosmeticMountRef.current);
- }
- }
- }, [isOpen, selectedTab, type, isCartPage]);
-
useEffect(() => {
if (!isCartPage) {
// 리뷰 요약 데이터 수신
@@ -130,18 +109,16 @@ export function Sidebar({
if (!type || type === "none") return null;
switch (type) {
case "food":
- return ;
+ return ;
case "cosmetic":
- return (
-
- );
+ return ;
case "health":
return ;
default:
return null;
}
case "detail":
- return ;
+ return ;
case "review":
return (
;
+}
+
+export const sendCosmeticDataRequest = (
+ payload: CosmeticRequestPayload,
+): Promise => {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ {
+ type: "FETCH_COSMETIC_DATA",
+ payload,
+ },
+ (response: unknown) => {
+ if (!response || typeof response !== "object") {
+ return reject(new Error("Invalid response format"));
+ }
+
+ const res = response as CosmeticAPIResponse;
+
+ if (res.status !== 200) {
+ return reject(new Error(`API 실패 status: ${res.status}`));
+ }
+
+ const data = res.data;
+ if (!data || typeof data !== "object") {
+ return reject(new Error("올바르지 않은 응답 형식"));
+ }
+
+ const detected = Object.entries(data)
+ .filter(([_, v]) => v === true)
+ .map(([k]) => k);
+
+ resolve(detected);
+ },
+ );
+ });
+};
diff --git a/src/content/apiSetting/sendFoodDataRequest.tsx b/src/content/apiSetting/sendFoodDataRequest.tsx
index b344fa6..fefc007 100644
--- a/src/content/apiSetting/sendFoodDataRequest.tsx
+++ b/src/content/apiSetting/sendFoodDataRequest.tsx
@@ -17,13 +17,18 @@ interface FoodAPIResponse {
message?: string;
data?: {
allergyTypes: string[];
- overRecommendationNutrients: Nutrient[];
+ nutrientResponse: {
+ nutrientReferenceAmount: number;
+ overRecommendationNutrients: Nutrient[];
+ };
};
}
-
export const sendFoodDataRequest = (
payload: FoodRequestPayload,
-): Promise => {
+): Promise<{
+ allergyTypes: string[];
+ overRecommendationNutrients: Nutrient[];
+}> => {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{
@@ -44,13 +49,19 @@ export const sendFoodDataRequest = (
return reject(new Error(`API 실패 status: ${res.status}`));
}
- const actual = res.data?.data;
+ const data = res.data?.data;
- if (!actual) {
- return reject(new Error("data.data 필드가 없습니다"));
+ if (
+ !data ||
+ !data.nutrientResponse ||
+ !Array.isArray(data.allergyTypes)
+ ) {
+ return reject(new Error("올바르지 않은 응답 형식"));
}
- const { allergyTypes, overRecommendationNutrients } = actual;
+ const { allergyTypes, nutrientResponse } = data;
+ const overRecommendationNutrients =
+ nutrientResponse.overRecommendationNutrients;
const nutrientsValid =
Array.isArray(overRecommendationNutrients) &&
@@ -60,14 +71,17 @@ export const sendFoodDataRequest = (
typeof n.percentage === "number",
);
- const allergiesValid =
- Array.isArray(allergyTypes) &&
- allergyTypes.every((item) => typeof item === "string");
+ const allergiesValid = allergyTypes.every(
+ (item) => typeof item === "string",
+ );
if (nutrientsValid && allergiesValid) {
- resolve(actual);
+ resolve({
+ allergyTypes,
+ overRecommendationNutrients,
+ });
} else {
- console.error(" allergyTypes or nutrients 타입 오류", {
+ console.error("응답 검증 실패:", {
allergyTypes,
overRecommendationNutrients,
});
diff --git a/src/content/apiSetting/sendHealthDataRequest.tsx b/src/content/apiSetting/sendHealthDataRequest.tsx
new file mode 100644
index 0000000..927c182
--- /dev/null
+++ b/src/content/apiSetting/sendHealthDataRequest.tsx
@@ -0,0 +1,56 @@
+interface HealthRequestPayload {
+ productId: string;
+ title: string;
+ html: string;
+ birthYear: number;
+ gender: string;
+ allergies: string[];
+}
+
+interface HealthAPIResponse {
+ types: string[];
+}
+
+export const sendHealthDataRequest = (
+ payload: HealthRequestPayload,
+): Promise => {
+ return new Promise((resolve, reject) => {
+ console.log("[voim] HEALTH API 요청 payload:", payload);
+
+ chrome.runtime.sendMessage(
+ {
+ type: "FETCH_HEALTH_DATA",
+ payload,
+ },
+ (response: unknown) => {
+ if (!response || typeof response !== "object") {
+ console.error(
+ "[voim] HEALTH API 응답 형식 오류:",
+ response,
+ );
+ return reject(new Error("Invalid response format"));
+ }
+
+ const res = response as {
+ type: string;
+ data?: HealthAPIResponse;
+ error?: string;
+ };
+
+ if (res.type === "HEALTH_DATA_RESPONSE" && res.data?.types) {
+ console.log(
+ "[voim] HEALTH API 응답 데이터:",
+ res.data.types,
+ );
+ resolve(res.data.types);
+ } else {
+ console.error(
+ "[voim] HEALTH API 실패 응답:",
+ res.error ?? res,
+ );
+ reject(new Error(res.error ?? "Health API 응답 오류"));
+ }
+ },
+ );
+ });
+};
diff --git a/src/content/coupang/categoryHandler/categoryHandlerCosmetic.tsx b/src/content/coupang/categoryHandler/categoryHandlerCosmetic.tsx
deleted file mode 100644
index bb00d5f..0000000
--- a/src/content/coupang/categoryHandler/categoryHandlerCosmetic.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from "react";
-import { createRoot } from "react-dom/client";
-import { CosmeticComponent } from "@src/components/productComponents/cosmeticComponent";
-
-export const observeBreadcrumbCosmeticAndRender = (
- targetElement: HTMLElement,
-) => {
- if (!targetElement || targetElement.hasChildNodes()) {
- return;
- }
-
- const root = createRoot(targetElement);
- root.render();
-};
diff --git a/src/content/coupang/categoryHandler/categoryHandlerFood.tsx b/src/content/coupang/categoryHandler/categoryHandlerFood.tsx
deleted file mode 100644
index bff98f0..0000000
--- a/src/content/coupang/categoryHandler/categoryHandlerFood.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from "react";
-import { createRoot } from "react-dom/client";
-import { FoodComponent } from "../../../components/productComponents/foodComponent";
-
-export const observeBreadcrumbFoodAndRender = (targetElement: HTMLElement) => {
- const breadcrumbEl = document.querySelector(".breadcrumb, #breadcrumb");
- const rawText = breadcrumbEl?.textContent || "";
- const cleanedText = rawText.replace(/\s+/g, "");
-
- const containsOnlyFood =
- cleanedText.includes("식품") && !cleanedText.includes("건강식품");
-
- if (!containsOnlyFood) {
- return;
- }
-
- if (!targetElement) return;
- if (targetElement.hasChildNodes()) return;
-
- const root = createRoot(targetElement);
- root.render();
-};
diff --git a/src/content/coupang/categoryHandler/categoryHandlerHealth.tsx b/src/content/coupang/categoryHandler/categoryHandlerHealth.tsx
deleted file mode 100644
index 6287881..0000000
--- a/src/content/coupang/categoryHandler/categoryHandlerHealth.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-// import React from "react";
-// import { createRoot } from "react-dom/client";
-// import { HealthComponent } from "@src/components/productComponents/healthComponent";
-
-// export const observeBreadcrumbHealthAndRender = () => {
-// const observer = new MutationObserver(() => {
-// const breadcrumbEl = document.querySelector("#breadcrumb");
-// if (breadcrumbEl) {
-// console.log("[voim] #breadcrumb 발견됨 ");
-// observer.disconnect();
-// checkCategoryHealthAndRender();
-// }
-// });
-
-// observer.observe(document.body, {
-// childList: true,
-// subtree: true,
-// });
-
-// console.log("[voim] breadcrumb 감지 대기 중...");
-// };
-
-// export const checkCategoryHealthAndRender = () => {
-// const breadcrumbEl = document.querySelector("#breadcrumb");
-// if (!breadcrumbEl) {
-// console.log("#breadcrumb 엘리먼트를 찾을 수 없음 ");
-// return;
-// }
-
-// const rawText = breadcrumbEl.textContent || "";
-// const cleanedText = rawText.replace(/\s+/g, "");
-// console.log("[voim] breadcrumb 내용:", cleanedText);
-
-// const isFoodCategory =
-// cleanedText.includes("건강") &&
-// !cleanedText.includes("건강가전") &&
-// !cleanedText.includes("건강도서");
-// if (!isFoodCategory) {
-// console.log("[voim] 헬스 카테고리가 아님");
-// return;
-// }
-
-// if (document.getElementById("voim-health-component")) {
-// console.log("[voim] 이미 컴포넌트 렌더링됨");
-// return;
-// }
-
-// const container = document.createElement("div");
-// container.id = "voim-health-component";
-// document.body.appendChild(container);
-
-// const root = createRoot(container);
-// root.render();
-// console.log("[voim] HealthComponent 렌더링 완료 ");
-// };
diff --git a/src/content/index.tsx b/src/content/index.tsx
index 7c24418..b9a2631 100644
--- a/src/content/index.tsx
+++ b/src/content/index.tsx
@@ -44,7 +44,7 @@ initDomObserver(() => true);
processImages();
-export const observeAndStoreCategoryType = () => {
+export const observeAndStoreCategoryType = async () => {
const isCoupangProductPage =
/^https:\/\/www\.coupang\.com\/vp\/products\/[0-9]+/.test(
location.href,
@@ -55,6 +55,19 @@ export const observeAndStoreCategoryType = () => {
return;
}
+ const category = await detectCategoryType();
+ console.log("[voim] 감지된 카테고리:", category);
+
+ chrome.storage.local.set({ "voim-category-type": category }, () => {
+ if (chrome.runtime.lastError) {
+ console.error(
+ "[voim] 카테고리 저장 실패:",
+ chrome.runtime.lastError.message,
+ );
+ } else {
+ console.log("[voim] 카테고리 저장 성공:", category);
+ }
+ });
const observer = new MutationObserver(() => {
const type = detectCategoryType();
if (type !== "none") {
@@ -74,28 +87,69 @@ export const observeAndStoreCategoryType = () => {
observer.disconnect();
}, 1500);
};
-
observeAndStoreCategoryType();
+const waitForEl = (
+ selector: string,
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const el = document.querySelector(selector);
+ if (el) return resolve(el);
+
+ const observer = new MutationObserver(() => {
+ const found = document.querySelector(selector);
+ if (found) {
+ observer.disconnect();
+ resolve(found);
+ }
+ });
+
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ setTimeout(() => {
+ observer.disconnect();
+ resolve(null);
+ }, timeout);
+ });
+};
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message.type === "GET_PRODUCT_TITLE") {
+ const titleEl = document.querySelector("h1.prod-buy-header__title");
+ const title = titleEl?.textContent?.trim() ?? "";
+ console.debug("[voim][content] 추출된 title:", title);
+ sendResponse({ title });
+ return true;
+ }
+ return false;
+});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_VENDOR_HTML") {
- try {
- const vendorEl = document.querySelector(".vendor-item");
- const rawHtml =
- vendorEl?.outerHTML
- ?.replace(/\sonerror=\"[^\"]*\"/g, "")
- .replace(/\n/g, "")
- .trim() ?? "";
-
- sendResponse({ html: rawHtml });
- } catch (e) {
- sendResponse({ html: "" });
- }
+ waitForEl(".vendor-item").then((vendorEl) => {
+ if (!vendorEl) {
+ console.warn("[voim][content] .vendor-item 감지 실패");
+ sendResponse({ html: "", productId: "" });
+ return;
+ }
+
+ const rawHtml = vendorEl.outerHTML
+ .replace(/\sonerror=\"[^\"]*\"/g, "")
+ .replace(/\n/g, "")
+ .trim();
+
+ const match = window.location.href.match(/products\/(\d+)/);
+ const productId = match?.[1] ?? "";
+
+ console.log("[voim][content] 감지 성공:", {
+ html: rawHtml.slice(0, 100),
+ productId,
+ });
+
+ sendResponse({ html: rawHtml, productId });
+ });
return true;
}
-
- return false;
});
const isProductDetailPage = () => {
diff --git a/src/tabs/myInfo/components/AllergySelectForm.tsx b/src/tabs/myInfo/components/AllergySelectForm.tsx
index 899e051..73ea728 100644
--- a/src/tabs/myInfo/components/AllergySelectForm.tsx
+++ b/src/tabs/myInfo/components/AllergySelectForm.tsx
@@ -7,6 +7,27 @@ import { IconButton } from "@src/components/IconButton";
import { useTheme } from "@src/contexts/ThemeContext";
import { ContentBox } from "@src/components/contentBox";
import { CloseIcon } from "@src/components/icons/CloseIcon";
+export enum AllergyType {
+ EGG = "EGG", // 계란
+ MILK = "MILK", // 우유
+ BUCKWHEAT = "BUCKWHEAT", // 메밀
+ PEANUT = "PEANUT", // 땅콩
+ SOYBEAN = "SOYBEAN", // 대두
+ WHEAT = "WHEAT", // 밀
+ PINE_NUT = "PINE_NUT", // 잣
+ WALNUT = "WALNUT", // 호두
+ CRAB = "CRAB", // 게
+ SHRIMP = "SHRIMP", // 새우
+ SQUID = "SQUID", // 오징어
+ MACKEREL = "MACKEREL", // 고등어
+ SHELLFISH = "SHELLFISH", // 조개류
+ PEACH = "PEACH", // 복숭아
+ TOMATO = "TOMATO", // 토마토
+ CHICKEN = "CHICKEN", // 닭고기
+ PORK = "PORK", // 돼지고기
+ BEEF = "BEEF", // 쇠고기
+ SULFITE = "SULFITE", // 아황산류
+}
const allergyData = {
식물성: ["메밀", "대두", "밀", "땅콩", "잣", "호두", "토마토", "복숭아"],
@@ -14,6 +35,27 @@ const allergyData = {
해산물: ["게", "새우", "오징어", "고등어", "조개류"],
"첨가물 및 기타": ["아황산류"],
} as const;
+const allergyNameToEnumMap: Record = {
+ 계란: AllergyType.EGG,
+ 우유: AllergyType.MILK,
+ 메밀: AllergyType.BUCKWHEAT,
+ 땅콩: AllergyType.PEANUT,
+ 대두: AllergyType.SOYBEAN,
+ 밀: AllergyType.WHEAT,
+ 잣: AllergyType.PINE_NUT,
+ 호두: AllergyType.WALNUT,
+ 게: AllergyType.CRAB,
+ 새우: AllergyType.SHRIMP,
+ 오징어: AllergyType.SQUID,
+ 고등어: AllergyType.MACKEREL,
+ 조개류: AllergyType.SHELLFISH,
+ 복숭아: AllergyType.PEACH,
+ 토마토: AllergyType.TOMATO,
+ 닭고기: AllergyType.CHICKEN,
+ 돼지고기: AllergyType.PORK,
+ 쇠고기: AllergyType.BEEF,
+ 아황산류: AllergyType.SULFITE,
+};
type AllergyCategory = keyof typeof allergyData;
@@ -113,6 +155,9 @@ export function AllergySelectForm({ onComplete }: { onComplete?: () => void }) {
{
+ const allergyEnumArray = selectedAllergies
+ .map((name) => allergyNameToEnumMap[name])
+ .filter(Boolean) as AllergyType[];
const menuButtons = document.querySelectorAll(
'[data-testid="menubar-content"] button',
);
@@ -128,7 +173,7 @@ export function AllergySelectForm({ onComplete }: { onComplete?: () => void }) {
chrome.storage.local.set(
{
- Allergies: selectedAllergies,
+ Allergies: allergyEnumArray,
},
() => {
onComplete?.();