diff --git a/src/background/index.ts b/src/background/index.ts index b8806f7..5d72489 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -115,39 +115,57 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // FOOD API if (message.type === "FETCH_FOOD_DATA") { - const payload = message.payload; + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const activeTab = tabs[0]; + if (!activeTab?.url) { + sendResponse({ + status: 400, + error: "상품 페이지를 찾을 수 없습니다.", + }); + return; + } - fetch("https://voim.store/api/v1/products/foods", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }) - .then((res) => res.json()) - .then((data) => { - logger.debug("FOOD API 응답 성공:", data); - if (sender.tab?.id) { - chrome.tabs.sendMessage(sender.tab.id, { - type: "FOOD_DATA_RESPONSE", - data, - }); - } - sendResponse({ status: 200, data }); - }) - .catch((err) => { - console.error( - "[voim][background] FOOD API 요청 실패:", - err.message, - ); - if (sender.tab?.id) { - chrome.tabs.sendMessage(sender.tab.id, { - type: "FOOD_DATA_ERROR", - error: err.message, + const productId = activeTab.url.match(/vp\/products\/(\d+)/)?.[1]; + if (!productId) { + sendResponse({ + status: 400, + error: "상품 ID를 찾을 수 없습니다.", + }); + return; + } + + const payload = { + ...message.payload, + productId, + }; + + fetch("https://voim.store/api/v1/products/foods", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).then(async (res) => { + const text = await res.text(); + console.log("[voim] 응답 상태 코드:", res.status); + console.log("[voim] 응답 원문:", text); + try { + const json = JSON.parse(text); + if (res.ok) { + sendResponse({ status: 200, data: json }); + } else { + sendResponse({ + status: res.status, + error: json?.message ?? "에러 발생", + }); + } + } catch (err) { + console.error("[voim] JSON 파싱 실패", text); + sendResponse({ + status: res.status, + error: "JSON 파싱 실패", }); } - sendResponse({ status: 500, error: err.message }); }); + }); return true; } @@ -255,26 +273,36 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // COSMETIC API if (message.type === "FETCH_COSMETIC_DATA") { const { productId, html } = message.payload; + fetch("https://voim.store/api/v1/cosmetic", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productId, html }), }) - .then((res) => { - return res.json(); + .then(async (res) => { + const json = await res.json(); + console.log("[voim][background] 응답 원문:", json); + return json; }) .then((data) => { const raw = data?.data; + if (!raw || typeof raw !== "object") { console.warn( "[voim][background] data.data 형식 이상함:", raw, ); + sendResponse({ + type: "COSMETIC_DATA_ERROR", + error: "API 응답 형식 오류", + }); + return; } - const parsedList = Object.entries(raw || {}) - .filter(([_, v]) => v === true) - .map(([k]) => k); + sendResponse({ + type: "COSMETIC_DATA_RESPONSE", + data: raw, + }); if (sender.tab?.id) { chrome.tabs.sendMessage(sender.tab.id, { @@ -282,30 +310,26 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { data: raw, }); } - - sendResponse({ - type: "COSMETIC_DATA_RESPONSE", - data: raw, - }); }) .catch((err) => { - console.error("[voim][background] COSMETIC 요청 실패:", err); + console.error("[voim][background] COSMETIC 요청 실패:", err); + sendResponse({ + type: "COSMETIC_DATA_ERROR", + error: err.message, + }); + if (sender.tab?.id) { chrome.tabs.sendMessage(sender.tab.id, { type: "COSMETIC_DATA_ERROR", error: err.message, }); } - sendResponse({ - type: "COSMETIC_DATA_ERROR", - error: err.message, - }); }); return true; } - // REVIEW SUMMARY API + // // REVIEW SUMMARY API if (message.type === "FETCH_REVIEW_SUMMARY") { const { productId, reviewRating, reviews } = message.payload; @@ -425,3 +449,34 @@ chrome.action.onClicked.addListener(async (tab) => { logger.error("툴바 아이콘 클릭 처리 중 오류 발생:", error); } }); +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === "GET_PRODUCT_TITLE") { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tabId = tabs[0]?.id; + if (!tabId) { + sendResponse({ title: "" }); + return; + } + + chrome.tabs.sendMessage( + tabId, + { type: "GET_PRODUCT_TITLE" }, + (response) => { + if (chrome.runtime.lastError) { + console.error( + "[voim][background] title 요청 실패:", + chrome.runtime.lastError.message, + ); + sendResponse({ title: "" }); + } else { + sendResponse(response); + } + }, + ); + }); + + return true; + } + + return false; +}); diff --git a/src/components/imageCheck/controlImage.tsx b/src/components/imageCheck/controlImage.tsx index 51c6a26..790411d 100644 --- a/src/components/imageCheck/controlImage.tsx +++ b/src/components/imageCheck/controlImage.tsx @@ -110,9 +110,7 @@ export const ControlImage: React.FC = ({ targetImg }) => { setShowModal(true); }} > - 이미지 분석 클릭 -
- 단축키: ALT + I + 이미지 분석 )} diff --git a/src/components/imageCheck/imageModal.tsx b/src/components/imageCheck/imageModal.tsx index 5c15e82..998596f 100644 --- a/src/components/imageCheck/imageModal.tsx +++ b/src/components/imageCheck/imageModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { sendImageAnalysisRequest } from "../../content/apiSetting/sendImageAnalysisRequest"; import { Player } from "@lottiefiles/react-lottie-player"; +import Loading from "../Loading/component"; interface ImageModalProps { imageUrl: string; @@ -86,13 +87,21 @@ export const ImageModal: React.FC = ({ {analysis}

) : ( -
- {/* */} +
+
+ +

= { 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?.();