refactor: plan/report hook 에서 mock 분기 / nav state / 시각 fallback 제거
- 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) <noreply@anthropic.com>main
parent
d85dc50bf3
commit
93674e4856
|
|
@ -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<string, MarketingPlan> = {
|
||||
'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<MarketingPlan | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clinicId, setClinicId] = useState<string | null>(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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, MarketingReport> = {
|
||||
'view-clinic': mockReport,
|
||||
'banobagi': mockReportBanobagi,
|
||||
'grand': mockReportGrand,
|
||||
'wonjin': mockReportWonjin,
|
||||
'ts': mockReportTs,
|
||||
'irum': mockReportIrum,
|
||||
'o2o': mockReportO2O,
|
||||
};
|
||||
|
||||
const DEMO_HANDLES: Record<string, Record<string, string | null>> = {
|
||||
'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<string, string | null> | null;
|
||||
/** DB row의 clinic_id (FK). 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
||||
clinicId: string | null;
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
report?: Record<string, unknown>;
|
||||
metadata?: {
|
||||
url: string;
|
||||
clinicName: string;
|
||||
generatedAt: string;
|
||||
socialHandles?: Record<string, string | null>;
|
||||
address?: string;
|
||||
services?: string[];
|
||||
};
|
||||
reportId?: string;
|
||||
}
|
||||
|
||||
export function useReport(id: string | undefined): UseReportResult {
|
||||
const [data, setData] = useState<MarketingReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEnriched, setIsEnriched] = useState(false);
|
||||
const [socialHandles, setSocialHandles] = useState<Record<string, string | null> | null>(null);
|
||||
const [clinicId, setClinicId] = useState<string | null>(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<string, unknown> | 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);
|
||||
}
|
||||
setSocialHandles(state.metadata.socialHandles || null);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to parse report data');
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Source 2: 리포트 ID로 백엔드에서 조회 (북마크/공유 링크)
|
||||
if (id) {
|
||||
getReport(id)
|
||||
.then((res) => {
|
||||
if (res.status !== 200) {
|
||||
throw new Error('리포트 조회에 실패했습니다.');
|
||||
}
|
||||
const row = res.data as unknown as Record<string, unknown>;
|
||||
const reportJson = (row.report as Record<string, unknown>) || 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<string, unknown>).status as string | undefined;
|
||||
if (status && status !== 'complete') {
|
||||
throw new Error(`리포트 생성 중입니다 (${status}). 잠시 후 새로고침 해주세요.`);
|
||||
const output = res.data;
|
||||
if (!output) {
|
||||
throw new Error('리포트 데이터가 비어있습니다.');
|
||||
}
|
||||
throw new Error('리포트 데이터가 비어있습니다. 분석을 다시 실행해주세요.');
|
||||
}
|
||||
|
||||
const scrapeData = row.scrape_data as Record<string, unknown> | 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<string, string> | null | undefined) ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
// 소셜 핸들 복원 우선순위: report.socialHandles > AI clinicInfo.socialMedia > scrape_data
|
||||
let handles = (reportJson.socialHandles as Record<string, string | null | string[]>) || null;
|
||||
if (!handles) {
|
||||
// clinicInfo의 AI 생성 socialMedia 시도
|
||||
const aiSocial = (reportJson.clinicInfo as Record<string, unknown>)?.socialMedia as Record<string, unknown> | undefined;
|
||||
const scrapeSocial = scrapeData
|
||||
? (scrapeData.clinic as Record<string, unknown>)?.socialMedia as Record<string, string> | 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<string, string | null> | null);
|
||||
|
||||
// V2: channel_data 컬럼 (report JSONB와 분리)
|
||||
// V1 호환: report JSONB 내부의 channelEnrichment
|
||||
const channelData = row.channel_data as Record<string, unknown> | 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 {
|
||||
const transformed = transformReportOutput(id, output, { url: '', generatedAt: '' });
|
||||
setData(transformed);
|
||||
setIsEnriched(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch report');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
return;
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 사용 가능한 데이터 소스 없음
|
||||
setError('리포트 데이터를 찾을 수 없습니다. 새 분석을 시작해주세요.');
|
||||
setIsLoading(false);
|
||||
}, [id, location.state]);
|
||||
|
||||
return { data, isLoading, error, isEnriched, socialHandles, clinicId };
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
isEnriched: false,
|
||||
socialHandles: null,
|
||||
clinicId: null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue