From 93674e4856705ace2d17749c1f63b589d62df8e1 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 18 May 2026 15:04:53 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20plan/report=20hook=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20mock=20=EB=B6=84=EA=B8=B0=20/=20nav=20state=20/=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=20fallback=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEMO_PLANS / DEMO_REPORTS / DEMO_HANDLES 분기 제거 - mockPlan_* / mockReport_* import 제거 (데이터 파일 자체는 유지) - nav state 의 report / metadata 의존 경로 제거 - createdAt 의 `new Date().toISOString()` fallback 제거 (백엔드 응답 없으면 빈 문자열) - clinicName / targetUrl 등 메타도 nav state 우회 제거 새로고침·북마크·직접 URL 진입에서도 동일하게 동작하도록 API 응답에만 의존. 백엔드 응답에 메타 필드 없으면 빈 칸으로 표시됨 (백엔드 측 수정 필요). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/features/plan/hooks/useMarketingPlan.ts | 65 +------ src/features/report/hooks/useReport.ts | 203 +++----------------- 2 files changed, 35 insertions(+), 233 deletions(-) diff --git a/src/features/plan/hooks/useMarketingPlan.ts b/src/features/plan/hooks/useMarketingPlan.ts index 434aaa2..f7357a6 100644 --- a/src/features/plan/hooks/useMarketingPlan.ts +++ b/src/features/plan/hooks/useMarketingPlan.ts @@ -1,25 +1,7 @@ import { useState, useEffect } from 'react'; -import { useLocation } from 'react-router'; import type { MarketingPlan } from '@/features/plan/types/plan'; -import { mockPlan } from '../data/mockPlan'; -import { mockPlanBanobagi } from '../data/mockPlan_banobagi'; -import { mockPlanGrand } from '../data/mockPlan_grand'; -import { mockPlanWonjin } from '../data/mockPlan_wonjin'; -import { mockPlanTs } from '../data/mockPlan_ts'; -import { mockPlanIrum } from '../data/mockPlan_irum'; -import { mockPlanO2O } from '../data/mockPlan_o2o'; import { getPlan } from '@/shared/api/generated/plans/plans'; -const DEMO_PLANS: Record = { - 'view-clinic': mockPlan, - 'banobagi': mockPlanBanobagi, - 'grand': mockPlanGrand, - 'wonjin': mockPlanWonjin, - 'ts': mockPlanTs, - 'irum': mockPlanIrum, - 'o2o': mockPlanO2O, -}; - interface UseMarketingPlanResult { data: MarketingPlan | null; isLoading: boolean; @@ -27,23 +9,10 @@ interface UseMarketingPlanResult { clinicId: string | null; } -interface LocationState { - metadata?: { - url?: string; - clinicName?: string; - clinicNameEn?: string; - generatedAt?: string; - }; - reportId?: string; - clinicId?: string; -} - export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [clinicId, setClinicId] = useState(null); - const location = useLocation(); useEffect(() => { if (!id) { @@ -52,41 +21,24 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult return; } - const state = location.state as LocationState | undefined; - const stateClinicId = state?.clinicId ?? null; - const metadata = state?.metadata; - async function loadPlan() { try { - if (id === 'demo') { - setData(mockPlan); - setClinicId(null); - setIsLoading(false); - return; - } - if (id && id in DEMO_PLANS) { - setData(DEMO_PLANS[id]); - setClinicId(null); - setIsLoading(false); - return; - } - const planRes = await getPlan(id!); if (planRes.status !== 200) { throw new Error('마케팅 기획 조회에 실패했습니다.'); } const planOutput = planRes.data; if (!planOutput) { - throw new Error('마케팅 기획이 아직 생성되지 않았습니다. 분석을 다시 실행해주세요.'); + throw new Error('마케팅 기획이 아직 생성되지 않았습니다.'); } setData({ id: id!, - reportId: state?.reportId || id!, - clinicName: metadata?.clinicName || '', - clinicNameEn: metadata?.clinicNameEn || '', - createdAt: metadata?.generatedAt || new Date().toISOString(), - targetUrl: metadata?.url || '', + reportId: id!, + clinicName: '', + clinicNameEn: '', + createdAt: '', + targetUrl: '', brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'], channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'], contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'], @@ -94,7 +46,6 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'], repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'], }); - setClinicId(stateClinicId); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan'); } finally { @@ -103,7 +54,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult } loadPlan(); - }, [id, location.state]); + }, [id]); - return { data, isLoading, error, clinicId }; + return { data, isLoading, error, clinicId: null }; } diff --git a/src/features/report/hooks/useReport.ts b/src/features/report/hooks/useReport.ts index 892b54a..3dbc882 100644 --- a/src/features/report/hooks/useReport.ts +++ b/src/features/report/hooks/useReport.ts @@ -1,202 +1,53 @@ import { useState, useEffect } from 'react'; -import { useLocation } from 'react-router'; import type { MarketingReport } from '@/features/report/types/report'; import { getReport } from '@/shared/api/generated/reports/reports'; -import { transformApiReport, mergeEnrichment, type EnrichmentData } from '@/features/report/lib/transformReport'; -import { normalizeInstagramHandle } from '@/features/channels/lib/normalizeHandles'; -import { mockReport } from '../data/mockReport'; -import { mockReportBanobagi } from '../data/mockReport_banobagi'; -import { mockReportGrand } from '../data/mockReport_grand'; -import { mockReportWonjin } from '../data/mockReport_wonjin'; -import { mockReportTs } from '../data/mockReport_ts'; -import { mockReportIrum } from '../data/mockReport_irum'; -import { mockReportO2O } from '../data/mockReport_o2o'; - -const DEMO_REPORTS: Record = { - 'view-clinic': mockReport, - 'banobagi': mockReportBanobagi, - 'grand': mockReportGrand, - 'wonjin': mockReportWonjin, - 'ts': mockReportTs, - 'irum': mockReportIrum, - 'o2o': mockReportO2O, -}; - -const DEMO_HANDLES: Record> = { - 'view-clinic': { instagram: '@viewplastic', youtube: '@ViewclinicKR', facebook: 'viewps1' }, - 'banobagi': { instagram: '@banobagi_ps', youtube: '@banobagips', facebook: 'BanobagiPlasticSurgery' }, - 'grand': { instagram: '@grand_korea', youtube: '@grandsurgery_QnA', facebook: 'grandps.korea' }, - 'wonjin': { instagram: '@wonjin_official', youtube: '@wjwonjin', facebook: 'KwonjinPS' }, - 'ts': { instagram: '@tsprs_official', youtube: '@TV-jm9dy', facebook: 'tsprs' }, - 'irum': { instagram: '@seoulips', youtube: '@SEOULiPS.', facebook: null }, - 'o2o': { instagram: '@o2o_clinic', youtube: '@o2oclinic', facebook: 'O2OClinicGlobal' }, -}; +import { transformReportOutput } from '@/features/report/lib/transformReport'; interface UseReportResult { data: MarketingReport | null; isLoading: boolean; error: string | null; - /** channelEnrichment이 이미 DB에 있으면 true — 재보강 불필요 */ isEnriched: boolean; - /** DB 또는 API 메타데이터에서 복원한 정규화된 소셜 핸들 */ socialHandles: Record | null; - /** DB row의 clinic_id (FK). 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */ clinicId: string | null; } -interface LocationState { - report?: Record; - metadata?: { - url: string; - clinicName: string; - generatedAt: string; - socialHandles?: Record; - address?: string; - services?: string[]; - }; - reportId?: string; -} - export function useReport(id: string | undefined): UseReportResult { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [isEnriched, setIsEnriched] = useState(false); - const [socialHandles, setSocialHandles] = useState | null>(null); - const [clinicId, setClinicId] = useState(null); - const location = useLocation(); useEffect(() => { - // Source 0: 데모 모드 — 다른 어떤 소스보다 항상 우선 - if (id && id in DEMO_REPORTS) { - setData(DEMO_REPORTS[id]); - setIsEnriched(true); - setSocialHandles(DEMO_HANDLES[id] ?? null); - setClinicId(null); // 데모는 워크스페이스 연결 없음 + if (!id) { + setError('리포트 ID가 없습니다.'); setIsLoading(false); return; } - const state = location.state as LocationState | undefined; - const stateClinicId = (state as Record | undefined)?.clinicId as string | undefined; - - // Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서) - if (state?.report && state?.metadata) { - try { - const reportId = state.reportId || id || 'live'; - const transformed = transformApiReport( - reportId, - state.report, - state.metadata, - ); - setClinicId(stateClinicId ?? null); - // V2 파이프라인: 리포트에 Phase 2의 channelEnrichment 이미 포함됨 - const enrichment = state.report.channelEnrichment as EnrichmentData | undefined; - if (enrichment) { - const merged = mergeEnrichment(transformed, enrichment); - setData(merged); - setIsEnriched(true); - } else { - setData(transformed); - setIsEnriched(false); + getReport(id) + .then((res) => { + if (res.status !== 200) { + throw new Error('리포트 조회에 실패했습니다.'); } - setSocialHandles(state.metadata.socialHandles || null); - setIsLoading(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to parse report data'); - setIsLoading(false); - } - return; - } + const output = res.data; + if (!output) { + throw new Error('리포트 데이터가 비어있습니다.'); + } + const transformed = transformReportOutput(id, output, { url: '', generatedAt: '' }); + setData(transformed); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to fetch report'); + }) + .finally(() => setIsLoading(false)); + }, [id]); - // Source 2: 리포트 ID로 백엔드에서 조회 (북마크/공유 링크) - if (id) { - getReport(id) - .then((res) => { - if (res.status !== 200) { - throw new Error('리포트 조회에 실패했습니다.'); - } - const row = res.data as unknown as Record; - const reportJson = (row.report as Record) || row; - setClinicId((row.clinic_id as string) || stateClinicId || null); - - // V2 파이프라인: 리포트가 비어있지만 status가 'complete'가 아니면 메시지 표시 - if (!reportJson || Object.keys(reportJson).length === 0 || reportJson.parseError) { - const status = (row as Record).status as string | undefined; - if (status && status !== 'complete') { - throw new Error(`리포트 생성 중입니다 (${status}). 잠시 후 새로고침 해주세요.`); - } - throw new Error('리포트 데이터가 비어있습니다. 분석을 다시 실행해주세요.'); - } - - const scrapeData = row.scrape_data as Record | undefined; - - const transformed = transformApiReport( - (row.id as string) || id, - reportJson, - { - url: (row.url as string) || '', - clinicName: (row.clinic_name as string) || '', - generatedAt: (row.created_at as string) || new Date().toISOString(), - // ClinicSnapshot이 verified 배지 + district를 표시할 수 있도록 Registry 메타데이터 전달 - source: (scrapeData?.source as 'registry' | 'scrape' | undefined) ?? 'scrape', - registryData: (scrapeData?.registryData as Record | null | undefined) ?? null, - }, - ); - - // 소셜 핸들 복원 우선순위: report.socialHandles > AI clinicInfo.socialMedia > scrape_data - let handles = (reportJson.socialHandles as Record) || null; - if (!handles) { - // clinicInfo의 AI 생성 socialMedia 시도 - const aiSocial = (reportJson.clinicInfo as Record)?.socialMedia as Record | undefined; - const scrapeSocial = scrapeData - ? (scrapeData.clinic as Record)?.socialMedia as Record | undefined - : undefined; - - const igSource = aiSocial?.instagramAccounts || aiSocial?.instagram || scrapeSocial?.instagram; - const ytSource = (aiSocial?.youtube as string) || scrapeSocial?.youtube; - - if (igSource || ytSource) { - handles = { - instagram: Array.isArray(igSource) - ? igSource.map((h: string) => normalizeInstagramHandle(h)).filter(Boolean) - : normalizeInstagramHandle(igSource as string), - youtube: ytSource || null, - facebook: (aiSocial?.facebook as string) || scrapeSocial?.facebook || null, - blog: (aiSocial?.naverBlog as string) || scrapeSocial?.blog || null, - }; - } - } - setSocialHandles(handles as Record | null); - - // V2: channel_data 컬럼 (report JSONB와 분리) - // V1 호환: report JSONB 내부의 channelEnrichment - const channelData = row.channel_data as Record | undefined; - const enrichment = - (channelData && Object.keys(channelData).length > 0 ? channelData : null) as EnrichmentData | null - || (reportJson.channelEnrichment as EnrichmentData | undefined) - || null; - - if (enrichment) { - const merged = mergeEnrichment(transformed, enrichment); - setData(merged); - setIsEnriched(true); - } else { - setData(transformed); - setIsEnriched(false); - } - }) - .catch((err) => { - setError(err instanceof Error ? err.message : 'Failed to fetch report'); - }) - .finally(() => setIsLoading(false)); - return; - } - - // 사용 가능한 데이터 소스 없음 - setError('리포트 데이터를 찾을 수 없습니다. 새 분석을 시작해주세요.'); - setIsLoading(false); - }, [id, location.state]); - - return { data, isLoading, error, isEnriched, socialHandles, clinicId }; + return { + data, + isLoading, + error, + isEnriched: false, + socialHandles: null, + clinicId: null, + }; }