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 { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
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';
|
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 {
|
interface UseMarketingPlanResult {
|
||||||
data: MarketingPlan | null;
|
data: MarketingPlan | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -27,23 +9,10 @@ interface UseMarketingPlanResult {
|
||||||
clinicId: string | null;
|
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 {
|
export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult {
|
||||||
const [data, setData] = useState<MarketingPlan | null>(null);
|
const [data, setData] = useState<MarketingPlan | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [clinicId, setClinicId] = useState<string | null>(null);
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
@ -52,41 +21,24 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = location.state as LocationState | undefined;
|
|
||||||
const stateClinicId = state?.clinicId ?? null;
|
|
||||||
const metadata = state?.metadata;
|
|
||||||
|
|
||||||
async function loadPlan() {
|
async function loadPlan() {
|
||||||
try {
|
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!);
|
const planRes = await getPlan(id!);
|
||||||
if (planRes.status !== 200) {
|
if (planRes.status !== 200) {
|
||||||
throw new Error('마케팅 기획 조회에 실패했습니다.');
|
throw new Error('마케팅 기획 조회에 실패했습니다.');
|
||||||
}
|
}
|
||||||
const planOutput = planRes.data;
|
const planOutput = planRes.data;
|
||||||
if (!planOutput) {
|
if (!planOutput) {
|
||||||
throw new Error('마케팅 기획이 아직 생성되지 않았습니다. 분석을 다시 실행해주세요.');
|
throw new Error('마케팅 기획이 아직 생성되지 않았습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
setData({
|
setData({
|
||||||
id: id!,
|
id: id!,
|
||||||
reportId: state?.reportId || id!,
|
reportId: id!,
|
||||||
clinicName: metadata?.clinicName || '',
|
clinicName: '',
|
||||||
clinicNameEn: metadata?.clinicNameEn || '',
|
clinicNameEn: '',
|
||||||
createdAt: metadata?.generatedAt || new Date().toISOString(),
|
createdAt: '',
|
||||||
targetUrl: metadata?.url || '',
|
targetUrl: '',
|
||||||
brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'],
|
brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'],
|
||||||
channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'],
|
channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'],
|
||||||
contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'],
|
contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'],
|
||||||
|
|
@ -94,7 +46,6 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'],
|
assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'],
|
||||||
repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'],
|
repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'],
|
||||||
});
|
});
|
||||||
setClinicId(stateClinicId);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
|
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -103,7 +54,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPlan();
|
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 { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
import type { MarketingReport } from '@/features/report/types/report';
|
import type { MarketingReport } from '@/features/report/types/report';
|
||||||
import { getReport } from '@/shared/api/generated/reports/reports';
|
import { getReport } from '@/shared/api/generated/reports/reports';
|
||||||
import { transformApiReport, mergeEnrichment, type EnrichmentData } from '@/features/report/lib/transformReport';
|
import { transformReportOutput } 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' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseReportResult {
|
interface UseReportResult {
|
||||||
data: MarketingReport | null;
|
data: MarketingReport | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** channelEnrichment이 이미 DB에 있으면 true — 재보강 불필요 */
|
|
||||||
isEnriched: boolean;
|
isEnriched: boolean;
|
||||||
/** DB 또는 API 메타데이터에서 복원한 정규화된 소셜 핸들 */
|
|
||||||
socialHandles: Record<string, string | null> | null;
|
socialHandles: Record<string, string | null> | null;
|
||||||
/** DB row의 clinic_id (FK). 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
|
||||||
clinicId: string | null;
|
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 {
|
export function useReport(id: string | undefined): UseReportResult {
|
||||||
const [data, setData] = useState<MarketingReport | null>(null);
|
const [data, setData] = useState<MarketingReport | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
// Source 0: 데모 모드 — 다른 어떤 소스보다 항상 우선
|
if (!id) {
|
||||||
if (id && id in DEMO_REPORTS) {
|
setError('리포트 ID가 없습니다.');
|
||||||
setData(DEMO_REPORTS[id]);
|
|
||||||
setIsEnriched(true);
|
|
||||||
setSocialHandles(DEMO_HANDLES[id] ?? null);
|
|
||||||
setClinicId(null); // 데모는 워크스페이스 연결 없음
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = location.state as LocationState | undefined;
|
getReport(id)
|
||||||
const stateClinicId = (state as Record<string, unknown> | undefined)?.clinicId as string | undefined;
|
.then((res) => {
|
||||||
|
if (res.status !== 200) {
|
||||||
// Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서)
|
throw new Error('리포트 조회에 실패했습니다.');
|
||||||
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);
|
const output = res.data;
|
||||||
setIsLoading(false);
|
if (!output) {
|
||||||
} catch (err) {
|
throw new Error('리포트 데이터가 비어있습니다.');
|
||||||
setError(err instanceof Error ? err.message : 'Failed to parse report data');
|
}
|
||||||
setIsLoading(false);
|
const transformed = transformReportOutput(id, output, { url: '', generatedAt: '' });
|
||||||
}
|
setData(transformed);
|
||||||
return;
|
})
|
||||||
}
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch report');
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// Source 2: 리포트 ID로 백엔드에서 조회 (북마크/공유 링크)
|
return {
|
||||||
if (id) {
|
data,
|
||||||
getReport(id)
|
isLoading,
|
||||||
.then((res) => {
|
error,
|
||||||
if (res.status !== 200) {
|
isEnriched: false,
|
||||||
throw new Error('리포트 조회에 실패했습니다.');
|
socialHandles: null,
|
||||||
}
|
clinicId: null,
|
||||||
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}). 잠시 후 새로고침 해주세요.`);
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue