Compare commits
No commits in common. "82f166a80e56d284cb21b55208a6cbe8e673f7c6" and "5f7e0051cd75c46d2e4e7587cdbefd280df0cc3f" have entirely different histories.
82f166a80e
...
5f7e0051cd
|
|
@ -1,7 +1 @@
|
||||||
# 백엔드 API 베이스 URL
|
|
||||||
# - Vite proxy 타겟 (개발 시 /api/* 요청 프록시)
|
|
||||||
# - orval 이 OpenAPI 스펙을 가져오는 주소 (${BASE}/openapi.json)
|
|
||||||
VITE_API_BASE_URL=http://localhost:8001
|
VITE_API_BASE_URL=http://localhost:8001
|
||||||
|
|
||||||
# 백엔드 API 키 — 모든 요청에 x-api-key 헤더로 전송
|
|
||||||
VITE_API_KEY=
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ try {
|
||||||
// .env 파일이 없으면 무시 (기본값 사용)
|
// .env 파일이 없으면 무시 (기본값 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
|
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'http://localhost:8001'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
api: {
|
api: {
|
||||||
|
|
@ -20,8 +20,7 @@ export default defineConfig({
|
||||||
client: 'react-query',
|
client: 'react-query',
|
||||||
httpClient: 'fetch',
|
httpClient: 'fetch',
|
||||||
clean: true,
|
clean: true,
|
||||||
// prettier 를 로컬에 설치하지 않은 환경에서도 동작하도록 비활성. 필요하면 `yarn add -D prettier` 후 true 로.
|
prettier: true,
|
||||||
prettier: false,
|
|
||||||
override: {
|
override: {
|
||||||
mutator: {
|
mutator: {
|
||||||
path: './src/shared/api/api.ts',
|
path: './src/shared/api/api.ts',
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,7 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"api:gen": "orval --config ./orval.config.ts",
|
"api:gen": "orval --config ./orval.config.ts"
|
||||||
"d:up": "docker-compose up -d && docker-compose logs -f frontend",
|
|
||||||
"d:down": "docker-compose down",
|
|
||||||
"d:logs": "docker-compose logs -f --tail 100 frontend",
|
|
||||||
"d:sh": "docker-compose exec frontend sh",
|
|
||||||
"d:rebuild": "docker-compose up -d --build && docker-compose logs -f frontend"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
/**
|
|
||||||
* DevOnly — `/dev/*` 라우트 가드.
|
|
||||||
*
|
|
||||||
* `window.location.hostname` 이 로컬호스트 계열이 아닐 경우 루트로 리다이렉트.
|
|
||||||
* 클라이언트 사이드 가드라 보안 의미보다는 "운영 도메인에서 실수로 노출되는 것 방지" 용도.
|
|
||||||
* 진짜 차단이 필요하면 서버/CDN 레벨에서 경로를 막아야 한다.
|
|
||||||
*/
|
|
||||||
import { Navigate, Outlet } from 'react-router';
|
|
||||||
|
|
||||||
const LOCAL_HOSTNAMES = new Set([
|
|
||||||
'localhost',
|
|
||||||
'127.0.0.1',
|
|
||||||
'0.0.0.0',
|
|
||||||
'::1',
|
|
||||||
'[::1]',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isLocalHost(): boolean {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return LOCAL_HOSTNAMES.has(window.location.hostname);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DevOnly() {
|
|
||||||
if (!isLocalHost()) {
|
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
/**
|
|
||||||
* Dev: 클리닉 리스트 페이지.
|
|
||||||
*
|
|
||||||
* 백엔드 `GET /api/clinics` (listClinics) 응답을 표로 확인하는 개발용 화면.
|
|
||||||
* 운영 도메인 노출 방지는 라우트 단의 DevOnly 가드에 위임.
|
|
||||||
*/
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { useListClinics } from '@/shared/api/generated/clinics/clinics';
|
|
||||||
import { PageContainer } from '@/shared/ui/page-container';
|
|
||||||
import { Spinner } from '@/shared/ui/spinner';
|
|
||||||
import { EmptyState } from '@/shared/ui/empty-state';
|
|
||||||
import { Button } from '@/shared/ui/button';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
function formatDate(raw: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(raw).toLocaleString('ko-KR', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClinicsPage() {
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
|
|
||||||
const { data, isLoading, error, refetch, isFetching } = useListClinics(
|
|
||||||
{ limit: PAGE_SIZE, offset },
|
|
||||||
{ query: { staleTime: 0 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = data?.status === 200 ? data.data : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-20 pb-16">
|
|
||||||
<PageContainer>
|
|
||||||
<header className="flex items-end justify-between mb-6 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-[#6C5CE7] tracking-widest uppercase mb-2">
|
|
||||||
Dev · Clinics
|
|
||||||
</p>
|
|
||||||
<h1 className="font-serif text-3xl font-bold text-[#0A1128]">
|
|
||||||
클리닉 리스트
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">
|
|
||||||
GET /api/clinics · limit={PAGE_SIZE} · offset={offset}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
{isFetching ? <Spinner size="xs" /> : null}
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Spinner size="lg" className="text-[#6C5CE7]" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
|
||||||
클리닉 리스트 조회 실패: {String((error as Error)?.message ?? error)}
|
|
||||||
</div>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<EmptyState size="lg" hint="등록된 클리닉이 없습니다." />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)]">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-slate-50 text-slate-600">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">병원명</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">EN</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">상태</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">URL</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">주소</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">생성일</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium font-mono text-xs">hospital_id</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{items.map((c) => (
|
|
||||||
<tr key={c.hospital_id} className="hover:bg-slate-50/50">
|
|
||||||
<td className="px-4 py-3 font-medium text-[#0A1128]">
|
|
||||||
<Link
|
|
||||||
to={`/clinics/${c.hospital_id}`}
|
|
||||||
className="text-[#0A1128] hover:text-[#6C5CE7] hover:underline"
|
|
||||||
>
|
|
||||||
{c.hospital_name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-slate-600">{c.hospital_name_en ?? '—'}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-700">
|
|
||||||
{c.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-slate-600 max-w-[240px] truncate">
|
|
||||||
{c.url ? (
|
|
||||||
<a
|
|
||||||
href={c.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-[#6C5CE7] hover:underline"
|
|
||||||
>
|
|
||||||
{c.url}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-slate-600 max-w-[260px] truncate">{c.road_address ?? '—'}</td>
|
|
||||||
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDate(c.created_at)}</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-xs text-slate-400">{c.hospital_id}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 페이지네이션 — 정확한 total 이 없어서 단순 prev/next 만 노출 */}
|
|
||||||
<div className="flex items-center justify-end gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
|
|
||||||
disabled={offset === 0 || isFetching}
|
|
||||||
>
|
|
||||||
이전
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setOffset((o) => o + PAGE_SIZE)}
|
|
||||||
disabled={items.length < PAGE_SIZE || isFetching}
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,7 @@
|
||||||
import { lazy } from 'react'
|
import { lazy } from 'react'
|
||||||
import DevOnly from './components/DevOnly'
|
|
||||||
|
|
||||||
const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
|
const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
|
||||||
// TODO: SDK 재생성으로 useListClinics 가 제거됨. 백엔드에 list 엔드포인트 재추가 후 복구.
|
|
||||||
// const ClinicsPage = lazy(() => import('./pages/ClinicsPage'))
|
|
||||||
|
|
||||||
// `/dev/*` 는 DevOnly 가드를 거쳐 로컬호스트에서만 접근 가능.
|
|
||||||
export const devRoutes = [
|
export const devRoutes = [
|
||||||
{
|
{ path: 'dev/components', element: <ComponentsPage /> },
|
||||||
element: <DevOnly />,
|
|
||||||
children: [
|
|
||||||
{ path: 'dev/components', element: <ComponentsPage /> },
|
|
||||||
// { path: 'dev/clinics', element: <ClinicsPage /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ export default function ChannelStrategy({ channels }: ChannelStrategyProps) {
|
||||||
|
|
||||||
{/* Content Types */}
|
{/* Content Types */}
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{(ch.contentTypes ?? []).map((type) => (
|
{ch.contentTypes.map((type) => (
|
||||||
<span
|
<span
|
||||||
key={type}
|
key={type}
|
||||||
className="bg-slate-50 border border-slate-100 rounded-full px-3 py-1 text-xs text-slate-600 font-medium shadow-[2px_3px_6px_rgba(0,0,0,0.04)]"
|
className="bg-slate-50 border border-slate-100 rounded-full px-3 py-1 text-xs text-slate-600 font-medium shadow-[2px_3px_6px_rgba(0,0,0,0.04)]"
|
||||||
|
|
@ -105,7 +105,7 @@ export default function ChannelStrategy({ channels }: ChannelStrategyProps) {
|
||||||
|
|
||||||
{/* Format Guidelines */}
|
{/* Format Guidelines */}
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{(ch.formatGuidelines ?? []).map((guideline, i) => (
|
{ch.formatGuidelines.map((guideline, i) => (
|
||||||
<li key={i} className="flex items-start gap-2">
|
<li key={i} className="flex items-start gap-2">
|
||||||
<span className="shrink-0 w-2 h-2 rounded-full bg-[#6C5CE7] mt-2" />
|
<span className="shrink-0 w-2 h-2 rounded-full bg-[#6C5CE7] mt-2" />
|
||||||
<span className="text-sm text-slate-700">{guideline}</span>
|
<span className="text-sm text-slate-700">{guideline}</span>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* PDF: 브라우저 네이티브 인쇄(window.print) → "PDF로 저장"
|
* PDF: 브라우저 네이티브 인쇄(window.print) → "PDF로 저장"
|
||||||
* CSV: 기획의 표 데이터를 다중 섹션 CSV 단일 파일로 다운로드
|
* CSV: 기획의 표 데이터를 다중 섹션 CSV 단일 파일로 다운로드
|
||||||
*/
|
*/
|
||||||
import { FileText, FileSpreadsheet, ChevronDown, Loader2 } from 'lucide-react';
|
import { Download, FileText, FileSpreadsheet, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -43,6 +43,7 @@ export function PlanDownloadMenuButton({ filename, plan, className }: PlanDownlo
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Download size={12} />
|
||||||
다운로드
|
다운로드
|
||||||
<ChevronDown size={12} className="opacity-70" />
|
<ChevronDown size={12} className="opacity-70" />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,105 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import { useLocation } from 'react-router';
|
||||||
|
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '@/features/plan/types/plan';
|
||||||
|
import { transformReportToPlan } from '../lib/transformPlan';
|
||||||
|
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 { getReport } from '@/shared/api/generated/reports/reports';
|
||||||
import { getPlan } from '@/shared/api/generated/plans/plans';
|
import { getPlan } from '@/shared/api/generated/plans/plans';
|
||||||
|
|
||||||
|
// TODO(migration): 'analysis_runs' / 'clinics' / 'content_plans' 테이블 직접 조회는
|
||||||
|
// 현재 백엔드에 대응 엔드포인트 없음. 우선 reports/plans 엔드포인트만 활용하고,
|
||||||
|
// clinic 메타 일부 필드는 빈 값으로 둠.
|
||||||
|
|
||||||
|
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;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
||||||
clinicId: string | null;
|
clinicId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
report?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
reportId?: string;
|
||||||
|
clinicId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* content_plans DB row으로부터 MarketingPlan을 빌드.
|
||||||
|
* content_plans는 AI 생성 전략을 JSONB 컬럼에 저장.
|
||||||
|
*/
|
||||||
|
function buildPlanFromContentPlans(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
clinicName: string,
|
||||||
|
clinicNameEn: string,
|
||||||
|
targetUrl: string,
|
||||||
|
): MarketingPlan {
|
||||||
|
const channelStrategies = (row.channel_strategies || []) as ChannelStrategyCard[];
|
||||||
|
const contentStrategy = (row.content_strategy || {}) as ContentStrategyData;
|
||||||
|
const calendar = (row.calendar || { weeks: [], monthlySummary: [] }) as CalendarData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
reportId: (row.run_id as string) || (row.id as string),
|
||||||
|
clinicName,
|
||||||
|
clinicNameEn,
|
||||||
|
createdAt: (row.created_at as string) || new Date().toISOString(),
|
||||||
|
targetUrl,
|
||||||
|
brandGuide: (row.brand_guide as MarketingPlan['brandGuide']) || {
|
||||||
|
colors: [],
|
||||||
|
fonts: [],
|
||||||
|
logoRules: [],
|
||||||
|
toneOfVoice: {
|
||||||
|
personality: ['전문적', '친근한', '신뢰할 수 있는'],
|
||||||
|
communicationStyle: '의료 전문 지식을 쉽고 친근하게 전달',
|
||||||
|
doExamples: [],
|
||||||
|
dontExamples: [],
|
||||||
|
},
|
||||||
|
channelBranding: [],
|
||||||
|
brandInconsistencies: [],
|
||||||
|
},
|
||||||
|
channelStrategies,
|
||||||
|
contentStrategy: {
|
||||||
|
pillars: contentStrategy.pillars || [],
|
||||||
|
typeMatrix: contentStrategy.typeMatrix || [],
|
||||||
|
workflow: contentStrategy.workflow || [
|
||||||
|
{ step: 1, name: '기획', description: 'AI 콘텐츠 주제 선정', owner: 'INFINITH AI', duration: '자동' },
|
||||||
|
{ step: 2, name: '제작', description: 'AI 초안 생성 + 의료진 감수', owner: 'INFINITH AI', duration: '1시간' },
|
||||||
|
{ step: 3, name: '편집', description: '영상/이미지 편집', owner: 'INFINITH Studio', duration: '30분' },
|
||||||
|
{ step: 4, name: '배포', description: '채널별 최적화 배포', owner: 'INFINITH Distribution', duration: '자동' },
|
||||||
|
{ step: 5, name: '분석', description: '성과 데이터 수집 + 전략 조정', owner: 'INFINITH Analytics', duration: '자동' },
|
||||||
|
],
|
||||||
|
repurposingSource: contentStrategy.repurposingSource || '1개 롱폼 영상',
|
||||||
|
repurposingOutputs: contentStrategy.repurposingOutputs || [],
|
||||||
|
},
|
||||||
|
calendar,
|
||||||
|
assetCollection: { assets: [], youtubeRepurpose: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|
@ -21,31 +108,76 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = location.state as LocationState | undefined;
|
||||||
|
const stateClinicId = state?.clinicId ?? null;
|
||||||
|
|
||||||
async function loadPlan() {
|
async function loadPlan() {
|
||||||
try {
|
try {
|
||||||
const planRes = await getPlan(id!);
|
// ─── 개발 / 데모: mock 데이터를 즉시 반환 ───
|
||||||
if (planRes.status !== 200) {
|
if (id === 'demo') {
|
||||||
throw new Error('마케팅 기획 조회에 실패했습니다.');
|
setData(mockPlan);
|
||||||
|
setClinicId(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const planOutput = planRes.data;
|
if (id && id in DEMO_PLANS) {
|
||||||
if (!planOutput) {
|
setData(DEMO_PLANS[id]);
|
||||||
throw new Error('마케팅 기획이 아직 생성되지 않았습니다.');
|
setClinicId(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setData({
|
// ─── 소스 1: plan 엔드포인트 시도 (AI 생성 전략) ───
|
||||||
id: id!,
|
const clinicName = '';
|
||||||
reportId: id!,
|
const clinicNameEn = '';
|
||||||
clinicName: '',
|
const targetUrl = '';
|
||||||
clinicNameEn: '',
|
|
||||||
createdAt: '',
|
try {
|
||||||
targetUrl: '',
|
const planRes = await getPlan(id!);
|
||||||
brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'],
|
if (planRes.status === 200 && planRes.data) {
|
||||||
channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'],
|
const planRow = planRes.data as unknown as Record<string, unknown>;
|
||||||
contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'],
|
const plan = buildPlanFromContentPlans(
|
||||||
calendar: planOutput.calendar as MarketingPlan['calendar'],
|
planRow,
|
||||||
assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'],
|
clinicName,
|
||||||
repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'],
|
clinicNameEn,
|
||||||
|
targetUrl,
|
||||||
|
);
|
||||||
|
setData(plan);
|
||||||
|
setClinicId((planRow.clinic_id as string) || stateClinicId);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// report 기반 폴백으로 넘어감
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 소스 2: 네비게이션 state의 report 데이터 (폴백) ───
|
||||||
|
if (state?.report && state?.metadata) {
|
||||||
|
const plan = transformReportToPlan({
|
||||||
|
id: (state.reportId || id),
|
||||||
|
url: (state.metadata.url as string) || '',
|
||||||
|
clinic_name: (state.metadata.clinicName as string) || '',
|
||||||
|
report: state.report,
|
||||||
|
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setData(plan);
|
||||||
|
setClinicId(stateClinicId);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 소스 3: report를 fetch해서 변환 ───
|
||||||
|
const reportRes = await getReport(id!);
|
||||||
|
const reportRow = reportRes.data as unknown as Record<string, unknown>;
|
||||||
|
const plan = transformReportToPlan({
|
||||||
|
id: (reportRow.id as string) || id!,
|
||||||
|
url: (reportRow.url as string) || '',
|
||||||
|
clinic_name: (reportRow.clinic_name as string) || '',
|
||||||
|
report: reportRow,
|
||||||
|
created_at: (reportRow.created_at as string) || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
setData(plan);
|
||||||
|
setClinicId((reportRow.clinic_id as string) || 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 {
|
||||||
|
|
@ -54,7 +186,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPlan();
|
loadPlan();
|
||||||
}, [id]);
|
}, [id, location.state]);
|
||||||
|
|
||||||
return { data, isLoading, error, clinicId: null };
|
return { data, isLoading, error, clinicId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@
|
||||||
* 하단에 도입 문의 CTA(PlanCTA) 가 붙습니다.
|
* 하단에 도입 문의 CTA(PlanCTA) 가 붙습니다.
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams, useLocation } from 'react-router';
|
import { Link, useParams, useLocation } from 'react-router';
|
||||||
|
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||||
|
import { AppIcon } from '@/shared/icons/AppIcon';
|
||||||
import { useMarketingPlan } from '../hooks/useMarketingPlan';
|
import { useMarketingPlan } from '../hooks/useMarketingPlan';
|
||||||
import { ReportNav } from '@/features/report/components/ReportNav';
|
import { ReportNav } from '@/features/report/components/ReportNav';
|
||||||
|
import { PlanDownloadMenuButton } from '../components/PlanDownloadMenuButton';
|
||||||
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
|
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
|
||||||
import PlanBody from '../components/PlanBody';
|
import PlanBody from '../components/PlanBody';
|
||||||
|
|
||||||
|
|
@ -57,7 +60,31 @@ export default function GuestPlanPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav sections={PLAN_SECTIONS} />
|
<ReportNav
|
||||||
|
sections={PLAN_SECTIONS}
|
||||||
|
leftSlot={
|
||||||
|
<Link
|
||||||
|
to="/clinics/view-clinic"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
클리닉으로
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PlanDownloadMenuButton filename={`${data.clinicName}_Marketing_Plan`} plan={data} />
|
||||||
|
<Link
|
||||||
|
to={`/report/${id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-slate-700 bg-white border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<AppIcon kind="report" size={12} />
|
||||||
|
분석 리포트 보기
|
||||||
|
<ArrowRight size={11} className="opacity-80" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<PlanBody data={data} />
|
<PlanBody data={data} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@
|
||||||
* 본 차수 미포함, 후속 모듈로 이관. 아래 import/render 주석 처리.
|
* 본 차수 미포함, 후속 모듈로 이관. 아래 import/render 주석 처리.
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams, useLocation } from 'react-router';
|
import { Link, useParams, useLocation } from 'react-router';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { AppIcon } from '@/shared/icons/AppIcon';
|
||||||
import { useMarketingPlan } from '../hooks/useMarketingPlan';
|
import { useMarketingPlan } from '../hooks/useMarketingPlan';
|
||||||
import { ReportNav } from '@/features/report/components/ReportNav';
|
import { ReportNav } from '@/features/report/components/ReportNav';
|
||||||
|
import { PlanDownloadMenuButton } from '../components/PlanDownloadMenuButton';
|
||||||
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
|
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
|
||||||
import PlanBody from '../components/PlanBody';
|
import PlanBody from '../components/PlanBody';
|
||||||
import MyAssetUpload from '../components/MyAssetUpload';
|
import MyAssetUpload from '../components/MyAssetUpload';
|
||||||
|
|
@ -19,7 +22,7 @@ import MyAssetUpload from '../components/MyAssetUpload';
|
||||||
import WorkflowTracker from '../components/WorkflowTracker';
|
import WorkflowTracker from '../components/WorkflowTracker';
|
||||||
|
|
||||||
export default function UserPlanPage() {
|
export default function UserPlanPage() {
|
||||||
const { id } = useParams<{ clinicId: string; id: string }>();
|
const { clinicId, id } = useParams<{ clinicId: string; id: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
// const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
|
// const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
|
||||||
const { data, isLoading, error } = useMarketingPlan(id);
|
const { data, isLoading, error } = useMarketingPlan(id);
|
||||||
|
|
@ -59,9 +62,43 @@ export default function UserPlanPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baseRunId(이 플랜의 베이스 리포트) — 데이터 모델에 정식 필드 있으면 그걸 우선
|
||||||
|
const baseRunId =
|
||||||
|
(data as unknown as { baseReportId?: string }).baseReportId ||
|
||||||
|
(data as unknown as { reportId?: string }).reportId ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// 데모 환경: baseRunId 가 없으면 같은 plan id 로 리포트도 존재 (1:1 매핑)
|
||||||
|
const reportTargetId = baseRunId ?? id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav sections={PLAN_SECTIONS} />
|
<ReportNav
|
||||||
|
sections={PLAN_SECTIONS}
|
||||||
|
leftSlot={
|
||||||
|
<Link
|
||||||
|
to={`/clinics/${clinicId}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
클리닉으로
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PlanDownloadMenuButton filename={`${data.clinicName}_Marketing_Plan`} plan={data} />
|
||||||
|
{reportTargetId && (
|
||||||
|
<Link
|
||||||
|
to={`/clinics/${clinicId}/report/${reportTargetId}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all"
|
||||||
|
>
|
||||||
|
<AppIcon kind="report" size={12} />
|
||||||
|
기반 리포트
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<PlanBody data={data} />
|
<PlanBody data={data} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ComponentType, CSSProperties } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Youtube, Instagram, Globe, Star, Facebook, Search } from 'lucide-react';
|
import { Youtube, Instagram, Globe, Star, Facebook, Search } from 'lucide-react';
|
||||||
import { SectionWrapper } from './ui/SectionWrapper';
|
import { SectionWrapper } from './ui/SectionWrapper';
|
||||||
|
|
@ -10,7 +10,7 @@ interface ChannelOverviewProps {
|
||||||
channels: ChannelScore[];
|
channels: ChannelScore[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMap: Record<string, ComponentType<{ size?: number; className?: string; style?: CSSProperties }>> = {
|
const iconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
|
||||||
youtube: Youtube,
|
youtube: Youtube,
|
||||||
instagram: Instagram,
|
instagram: Instagram,
|
||||||
facebook: Facebook,
|
facebook: Facebook,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* PDF: 브라우저 네이티브 인쇄(window.print) → "PDF로 저장"
|
* PDF: 브라우저 네이티브 인쇄(window.print) → "PDF로 저장"
|
||||||
* CSV: 리포트의 표 데이터를 다중 섹션 CSV 단일 파일로 다운로드
|
* CSV: 리포트의 표 데이터를 다중 섹션 CSV 단일 파일로 다운로드
|
||||||
*/
|
*/
|
||||||
import { FileText, FileSpreadsheet, ChevronDown, Loader2 } from 'lucide-react';
|
import { Download, FileText, FileSpreadsheet, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -44,6 +44,7 @@ export function DownloadMenuButton({ filename, report, className }: DownloadMenu
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Download size={12} />
|
||||||
다운로드
|
다운로드
|
||||||
<ChevronDown size={12} className="opacity-70" />
|
<ChevronDown size={12} className="opacity-70" />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export default function ReportBody({ data }: ReportBodyProps) {
|
||||||
</SectionErrorBoundary>
|
</SectionErrorBoundary>
|
||||||
|
|
||||||
<SectionErrorBoundary>
|
<SectionErrorBoundary>
|
||||||
{hasValue(data.instagramAudit) && nonEmpty(data.instagramAudit.accounts) ? (
|
{hasValue(data.instagramAudit) && data.instagramAudit.handle ? (
|
||||||
<InstagramAudit data={data.instagramAudit} />
|
<InstagramAudit data={data.instagramAudit} />
|
||||||
) : (
|
) : (
|
||||||
<EmptySection id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 분석" />
|
<EmptySection id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 분석" />
|
||||||
|
|
@ -88,7 +88,7 @@ export default function ReportBody({ data }: ReportBodyProps) {
|
||||||
</SectionErrorBoundary>
|
</SectionErrorBoundary>
|
||||||
|
|
||||||
<SectionErrorBoundary>
|
<SectionErrorBoundary>
|
||||||
{hasValue(data.facebookAudit) && nonEmpty(data.facebookAudit.pages) ? (
|
{hasValue(data.facebookAudit) && (data.facebookAudit.handle || data.facebookAudit.followers > 0) ? (
|
||||||
<FacebookAudit data={data.facebookAudit} />
|
<FacebookAudit data={data.facebookAudit} />
|
||||||
) : (
|
) : (
|
||||||
<EmptySection id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석" />
|
<EmptySection id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation, useParams } from 'react-router';
|
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||||
import { useCreateClinic } from '@/shared/api/generated/clinics/clinics';
|
import { useCreateClinic } from '@/shared/api/generated/clinics/clinics';
|
||||||
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analysis/analysis';
|
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses';
|
||||||
import { AnalysisStatus } from '@/shared/api/model/analysisStatus';
|
import { AnalysisStatus } from '@/shared/api/model/analysisStatus';
|
||||||
import { sleep } from '@/shared/lib/utils';
|
import { sleep } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,202 @@
|
||||||
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 { transformReportOutput } from '@/features/report/lib/transformReport';
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
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(() => {
|
||||||
if (!id) {
|
// Source 0: 데모 모드 — 다른 어떤 소스보다 항상 우선
|
||||||
setError('리포트 ID가 없습니다.');
|
if (id && id in DEMO_REPORTS) {
|
||||||
|
setData(DEMO_REPORTS[id]);
|
||||||
|
setIsEnriched(true);
|
||||||
|
setSocialHandles(DEMO_HANDLES[id] ?? null);
|
||||||
|
setClinicId(null); // 데모는 워크스페이스 연결 없음
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getReport(id)
|
const state = location.state as LocationState | undefined;
|
||||||
.then((res) => {
|
const stateClinicId = (state as Record<string, unknown> | undefined)?.clinicId as string | undefined;
|
||||||
if (res.status !== 200) {
|
|
||||||
throw new Error('리포트 조회에 실패했습니다.');
|
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return {
|
// Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서)
|
||||||
data,
|
if (state?.report && state?.metadata) {
|
||||||
isLoading,
|
try {
|
||||||
error,
|
const reportId = state.reportId || id || 'live';
|
||||||
isEnriched: false,
|
const transformed = transformApiReport(
|
||||||
socialHandles: null,
|
reportId,
|
||||||
clinicId: null,
|
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}). 잠시 후 새로고침 해주세요.`);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo } from '@/features/report/types/report';
|
import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo } from '@/features/report/types/report';
|
||||||
import type { ReportOutput } from '@/shared/api/model/reportOutput';
|
|
||||||
import type { ChannelScore as SdkChannelScore } from '@/shared/api/model/channelScore';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* generate-report Edge Function의 API 응답.
|
* generate-report Edge Function의 API 응답.
|
||||||
|
|
@ -1268,204 +1266,3 @@ export function mergeEnrichment(
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// SDK ReportOutput → MarketingReport 변환
|
|
||||||
// 백엔드 OpenAPI 스펙(ReportOutput)을 그대로 받아 프론트엔드 컴포넌트가 기대하는
|
|
||||||
// MarketingReport 형태로 매핑. ReportOutput에 없는 풍부한 메타(clinicSnapshot,
|
|
||||||
// topVideos 등)는 빈 값으로 둠.
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
interface ReportMetadata {
|
|
||||||
url: string;
|
|
||||||
generatedAt?: string;
|
|
||||||
clinicName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelScoreToScoreCard(
|
|
||||||
channel: string,
|
|
||||||
icon: string,
|
|
||||||
ch: SdkChannelScore,
|
|
||||||
): ChannelScore {
|
|
||||||
return {
|
|
||||||
channel,
|
|
||||||
icon,
|
|
||||||
score: ch.score,
|
|
||||||
maxScore: 100,
|
|
||||||
status: scoreToSeverity(ch.score),
|
|
||||||
headline: ch.summary,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelScoreToDiagnosis(
|
|
||||||
category: string,
|
|
||||||
ch: SdkChannelScore | null | undefined,
|
|
||||||
): DiagnosisItem[] {
|
|
||||||
if (!ch) return [];
|
|
||||||
const items: DiagnosisItem[] = [];
|
|
||||||
for (const weakness of ch.weaknesses) {
|
|
||||||
items.push({ category, detail: weakness, severity: scoreToSeverity(ch.score) });
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "1주차: 채널 역할 정의" → { month: 1, title: '1주차', subtitle: '채널 역할 정의' } */
|
|
||||||
function parseRoadmapLine(line: string, index: number): import('../types/report').RoadmapMonth {
|
|
||||||
const colonIdx = line.indexOf(':');
|
|
||||||
if (colonIdx === -1) {
|
|
||||||
return { month: index + 1, title: `Week ${index + 1}`, subtitle: line.trim(), tasks: [] };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
month: index + 1,
|
|
||||||
title: line.slice(0, colonIdx).trim(),
|
|
||||||
subtitle: line.slice(colonIdx + 1).trim(),
|
|
||||||
tasks: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 백엔드 SDK의 ReportOutput을 그대로 받아 MarketingReport로 변환.
|
|
||||||
* - ReportOutput에 있는 필드: overall_score, {youtube,instagram,facebook,naver_blog,gangnam_unni}, conversion_strategy, roadmap, kpis
|
|
||||||
* - 그 외 풍부한 메타(clinicSnapshot, topVideos, followers 등)는 SDK에 없으므로 빈/기본값
|
|
||||||
*/
|
|
||||||
export function transformReportOutput(
|
|
||||||
reportId: string,
|
|
||||||
output: ReportOutput,
|
|
||||||
metadata: ReportMetadata,
|
|
||||||
): MarketingReport {
|
|
||||||
const domain = (() => {
|
|
||||||
try { return new URL(metadata.url).hostname; } catch { return metadata.url || ''; }
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 채널별 score card — SDK에 값이 있는 채널만
|
|
||||||
const channelScores: ChannelScore[] = [];
|
|
||||||
if (output.youtube) channelScores.push(channelScoreToScoreCard('YouTube', 'youtube', output.youtube));
|
|
||||||
if (output.instagram) channelScores.push(channelScoreToScoreCard('Instagram', 'instagram', output.instagram));
|
|
||||||
if (output.facebook) channelScores.push(channelScoreToScoreCard('Facebook', 'facebook', output.facebook));
|
|
||||||
if (output.naver_blog) channelScores.push(channelScoreToScoreCard('네이버 블로그', 'blog', output.naver_blog));
|
|
||||||
if (output.gangnam_unni) channelScores.push(channelScoreToScoreCard('강남언니', 'star', output.gangnam_unni));
|
|
||||||
|
|
||||||
// 전체 약점을 problemDiagnosis로 모음
|
|
||||||
const problemDiagnosis: DiagnosisItem[] = [
|
|
||||||
...channelScoreToDiagnosis('YouTube', output.youtube),
|
|
||||||
...channelScoreToDiagnosis('Instagram', output.instagram),
|
|
||||||
...channelScoreToDiagnosis('Facebook', output.facebook),
|
|
||||||
...channelScoreToDiagnosis('네이버 블로그', output.naver_blog),
|
|
||||||
...channelScoreToDiagnosis('강남언니', output.gangnam_unni),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: reportId,
|
|
||||||
createdAt: metadata.generatedAt || new Date().toISOString(),
|
|
||||||
targetUrl: metadata.url,
|
|
||||||
overallScore: output.overall_score,
|
|
||||||
|
|
||||||
clinicSnapshot: {
|
|
||||||
name: metadata.clinicName || '',
|
|
||||||
nameEn: '',
|
|
||||||
established: '',
|
|
||||||
yearsInBusiness: 0,
|
|
||||||
staffCount: 0,
|
|
||||||
leadDoctor: { name: '', credentials: '', rating: 0, reviewCount: 0 },
|
|
||||||
overallRating: 0,
|
|
||||||
totalReviews: 0,
|
|
||||||
priceRange: { min: '-', max: '-', currency: '₩' },
|
|
||||||
certifications: [],
|
|
||||||
mediaAppearances: [],
|
|
||||||
medicalTourism: [],
|
|
||||||
location: '',
|
|
||||||
nearestStation: '',
|
|
||||||
phone: '',
|
|
||||||
domain,
|
|
||||||
},
|
|
||||||
|
|
||||||
channelScores,
|
|
||||||
|
|
||||||
youtubeAudit: {
|
|
||||||
channelName: '',
|
|
||||||
handle: '',
|
|
||||||
subscribers: 0,
|
|
||||||
totalVideos: 0,
|
|
||||||
totalViews: 0,
|
|
||||||
weeklyViewGrowth: { absolute: 0, percentage: 0 },
|
|
||||||
estimatedMonthlyRevenue: { min: 0, max: 0 },
|
|
||||||
avgVideoLength: '-',
|
|
||||||
uploadFrequency: '-',
|
|
||||||
channelCreatedDate: '',
|
|
||||||
subscriberRank: '-',
|
|
||||||
channelDescription: output.youtube?.summary || '',
|
|
||||||
linkedUrls: [],
|
|
||||||
playlists: [],
|
|
||||||
topVideos: [],
|
|
||||||
diagnosis: channelScoreToDiagnosis('YouTube', output.youtube),
|
|
||||||
},
|
|
||||||
|
|
||||||
instagramAudit: {
|
|
||||||
accounts: output.instagram ? [{
|
|
||||||
handle: '',
|
|
||||||
language: 'KR',
|
|
||||||
label: '메인',
|
|
||||||
posts: 0,
|
|
||||||
followers: 0,
|
|
||||||
following: 0,
|
|
||||||
category: '의료/건강',
|
|
||||||
profileLink: '',
|
|
||||||
highlights: [],
|
|
||||||
reelsCount: 0,
|
|
||||||
contentFormat: '',
|
|
||||||
profilePhoto: '',
|
|
||||||
bio: output.instagram.summary,
|
|
||||||
}] : [],
|
|
||||||
diagnosis: channelScoreToDiagnosis('Instagram', output.instagram),
|
|
||||||
},
|
|
||||||
|
|
||||||
facebookAudit: {
|
|
||||||
pages: [],
|
|
||||||
diagnosis: channelScoreToDiagnosis('Facebook', output.facebook),
|
|
||||||
brandInconsistencies: [],
|
|
||||||
consolidationRecommendation: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
otherChannels: [
|
|
||||||
...(output.naver_blog ? [{
|
|
||||||
name: '네이버 블로그',
|
|
||||||
status: 'active' as const,
|
|
||||||
details: output.naver_blog.summary,
|
|
||||||
}] : []),
|
|
||||||
...(output.gangnam_unni ? [{
|
|
||||||
name: '강남언니',
|
|
||||||
status: 'active' as const,
|
|
||||||
details: output.gangnam_unni.summary,
|
|
||||||
}] : []),
|
|
||||||
],
|
|
||||||
|
|
||||||
websiteAudit: {
|
|
||||||
primaryDomain: domain,
|
|
||||||
additionalDomains: [],
|
|
||||||
snsLinksOnSite: false,
|
|
||||||
trackingPixels: [],
|
|
||||||
mainCTA: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
problemDiagnosis,
|
|
||||||
|
|
||||||
transformation: {
|
|
||||||
brandIdentity: [],
|
|
||||||
contentStrategy: [],
|
|
||||||
platformStrategies: [],
|
|
||||||
websiteImprovements: [],
|
|
||||||
newChannelProposals: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
roadmap: output.roadmap.map(parseRoadmapLine),
|
|
||||||
|
|
||||||
kpiDashboard: output.kpis.map((kpi) => ({
|
|
||||||
metric: kpi,
|
|
||||||
current: '-',
|
|
||||||
target3Month: '-',
|
|
||||||
target12Month: '-',
|
|
||||||
})),
|
|
||||||
|
|
||||||
screenshots: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@
|
||||||
* 본문은 UserReportPage 와 동일하나, 하단에 도입 문의 CTA가 추가되고
|
* 본문은 UserReportPage 와 동일하나, 하단에 도입 문의 CTA가 추가되고
|
||||||
* 워크스페이스용 액션바는 노출되지 않습니다.
|
* 워크스페이스용 액션바는 노출되지 않습니다.
|
||||||
*/
|
*/
|
||||||
import { useParams } from 'react-router';
|
import { Link, useParams } from 'react-router';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||||
|
import { AppIcon } from '@/shared/icons/AppIcon';
|
||||||
import { useReportPageData } from '../hooks/useReportPageData';
|
import { useReportPageData } from '../hooks/useReportPageData';
|
||||||
import { ReportNav } from '../components/ReportNav';
|
import { ReportNav } from '../components/ReportNav';
|
||||||
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
||||||
import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
|
import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
|
||||||
import { buildContactMailto } from '@/shared/lib/contact';
|
import { buildContactMailto } from '@/shared/lib/contact';
|
||||||
import ReportBody from '../components/ReportBody';
|
import ReportBody from '../components/ReportBody';
|
||||||
|
import { DownloadMenuButton } from '../components/DownloadMenuButton';
|
||||||
|
|
||||||
export default function GuestReportPage() {
|
export default function GuestReportPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -43,7 +45,34 @@ export default function GuestReportPage() {
|
||||||
return (
|
return (
|
||||||
<ScreenshotProvider screenshots={data.screenshots ?? []}>
|
<ScreenshotProvider screenshots={data.screenshots ?? []}>
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav sections={REPORT_SECTIONS} />
|
<ReportNav
|
||||||
|
sections={REPORT_SECTIONS}
|
||||||
|
leftSlot={
|
||||||
|
<Link
|
||||||
|
to="/clinics/view-clinic"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
클리닉으로
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DownloadMenuButton
|
||||||
|
filename={`${data.clinicSnapshot.name}_Marketing_Intelligence_Report`}
|
||||||
|
report={data}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
to={`/plan/${id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-brand-purple bg-brand-tint-purple/60 border border-brand-tint-lavender hover:bg-brand-tint-purple transition-colors"
|
||||||
|
>
|
||||||
|
<AppIcon kind="plan" size={12} />
|
||||||
|
마케팅 기획 보기
|
||||||
|
<ArrowRight size={11} className="opacity-80" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{enrichStatus === 'loading' && (
|
{enrichStatus === 'loading' && (
|
||||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] border border-slate-100">
|
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] border border-slate-100">
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,17 @@
|
||||||
* - 하단의 도입 문의 CTA 없음
|
* - 하단의 도입 문의 CTA 없음
|
||||||
*/
|
*/
|
||||||
import { Link, useParams } from 'react-router';
|
import { Link, useParams } from 'react-router';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { ArrowLeft, RefreshCw } from 'lucide-react';
|
||||||
|
import { AppIcon } from '@/shared/icons/AppIcon';
|
||||||
import { useReportPageData } from '../hooks/useReportPageData';
|
import { useReportPageData } from '../hooks/useReportPageData';
|
||||||
import { ReportNav } from '../components/ReportNav';
|
import { ReportNav } from '../components/ReportNav';
|
||||||
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
||||||
import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
|
import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
|
||||||
import ReportBody from '../components/ReportBody';
|
import ReportBody from '../components/ReportBody';
|
||||||
|
import { DownloadMenuButton } from '../components/DownloadMenuButton';
|
||||||
|
|
||||||
export default function UserReportPage() {
|
export default function UserReportPage() {
|
||||||
const { id } = useParams<{ clinicId: string; id: string }>();
|
const { clinicId, id } = useParams<{ clinicId: string; id: string }>();
|
||||||
const { data, isLoading, error, enrichStatus } = useReportPageData(id);
|
const { data, isLoading, error, enrichStatus } = useReportPageData(id);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -45,15 +47,37 @@ export default function UserReportPage() {
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav
|
<ReportNav
|
||||||
sections={REPORT_SECTIONS}
|
sections={REPORT_SECTIONS}
|
||||||
rightSlot={
|
leftSlot={
|
||||||
<Link
|
<Link
|
||||||
to={`/report/loading`}
|
to={`/clinics/${clinicId}`}
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all"
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} />
|
<ArrowLeft size={14} />
|
||||||
다시 분석
|
클리닉으로
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DownloadMenuButton
|
||||||
|
filename={`${data.clinicSnapshot.name}_Marketing_Intelligence_Report`}
|
||||||
|
report={data}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
to={`/report/loading`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
다시 분석
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/clinics/${clinicId}/plan/${id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
|
||||||
|
>
|
||||||
|
<AppIcon kind="plan" size={12} />
|
||||||
|
마케팅 기획 보기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{enrichStatus === 'loading' && (
|
{enrichStatus === 'loading' && (
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,17 @@
|
||||||
*/
|
*/
|
||||||
import ky, { type KyInstance } from 'ky'
|
import ky, { type KyInstance } from 'ky'
|
||||||
|
|
||||||
// import.meta 를 직접 쓰지 않는 이유: orval 이 mutator 를 cjs 로 esbuild 번들하는 과정에서
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||||
// `import.meta is not available in cjs` 경고가 떠 SDK 재생성 로그가 시끄럽다.
|
|
||||||
// 대신 vite.config.ts 의 `define` 으로 빌드 타임 치환되는 글로벌을 사용한다.
|
|
||||||
const API_BASE_URL = (__VITE_API_BASE_URL__ ?? '').replace(/\/$/, '')
|
|
||||||
|
|
||||||
export const kyInstance: KyInstance = ky.create({
|
export const kyInstance: KyInstance = ky.create({
|
||||||
timeout: 60_000,
|
timeout: 10_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
// orval generated 타입이 4xx/5xx 응답도 data로 받아오므로 throw 비활성
|
// orval generated 타입이 4xx/5xx 응답도 data로 받아오므로 throw 비활성
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
(request) => {
|
(request) => {
|
||||||
const apiKey = __VITE_API_KEY__;
|
const apiKey = import.meta.env.VITE_API_KEY;
|
||||||
if (apiKey) request.headers.set('x-api-key', apiKey);
|
if (apiKey) request.headers.set('x-api-key', apiKey);
|
||||||
},
|
},
|
||||||
// TODO: 인증 토큰 주입
|
// TODO: 인증 토큰 주입
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const getStartAnalysisUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return `/api/analysis`
|
return `/api/analyses`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const startAnalysis = async (analysisCreate: AnalysisCreate, options?: RequestInit): Promise<startAnalysisResponse> => {
|
export const startAnalysis = async (analysisCreate: AnalysisCreate, options?: RequestInit): Promise<startAnalysisResponse> => {
|
||||||
|
|
@ -154,7 +154,7 @@ export const getGetAnalysisStatusUrl = (runId: string,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return `/api/analysis/${runId}/status`
|
return `/api/analyses/${runId}/status`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAnalysisStatus = async (runId: string, options?: RequestInit): Promise<getAnalysisStatusResponse> => {
|
export const getAnalysisStatus = async (runId: string, options?: RequestInit): Promise<getAnalysisStatusResponse> => {
|
||||||
|
|
@ -174,7 +174,7 @@ export const getAnalysisStatus = async (runId: string, options?: RequestInit): P
|
||||||
|
|
||||||
export const getGetAnalysisStatusQueryKey = (runId?: string,) => {
|
export const getGetAnalysisStatusQueryKey = (runId?: string,) => {
|
||||||
return [
|
return [
|
||||||
`/api/analysis/${runId}/status`
|
`/api/analyses/${runId}/status`
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5,23 +5,28 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
useMutation,
|
||||||
useQuery
|
useQuery
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
DataTag,
|
DataTag,
|
||||||
DefinedInitialDataOptions,
|
DefinedInitialDataOptions,
|
||||||
DefinedUseQueryResult,
|
DefinedUseQueryResult,
|
||||||
|
MutationFunction,
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryFunction,
|
QueryFunction,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
UndefinedInitialDataOptions,
|
UndefinedInitialDataOptions,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseMutationResult,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
UseQueryResult
|
UseQueryResult
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GetPlan200,
|
HTTPValidationError,
|
||||||
HTTPValidationError
|
PlanCreate,
|
||||||
|
PlanResponse
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
import { customFetcher } from '../../api';
|
import { customFetcher } from '../../api';
|
||||||
|
|
@ -32,10 +37,100 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @summary Create Plan
|
||||||
|
*/
|
||||||
|
export type createPlanResponse201 = {
|
||||||
|
data: PlanResponse
|
||||||
|
status: 201
|
||||||
|
}
|
||||||
|
|
||||||
|
export type createPlanResponse422 = {
|
||||||
|
data: HTTPValidationError
|
||||||
|
status: 422
|
||||||
|
}
|
||||||
|
|
||||||
|
export type createPlanResponseSuccess = (createPlanResponse201) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type createPlanResponseError = (createPlanResponse422) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type createPlanResponse = (createPlanResponseSuccess | createPlanResponseError)
|
||||||
|
|
||||||
|
export const getCreatePlanUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/plans`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPlan = async (planCreate: PlanCreate, options?: RequestInit): Promise<createPlanResponse> => {
|
||||||
|
|
||||||
|
return customFetcher<createPlanResponse>(getCreatePlanUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
planCreate,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getCreatePlanMutationOptions = <TError = HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlan>>, TError,{data: PlanCreate}, TContext>, request?: SecondParameter<typeof customFetcher>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof createPlan>>, TError,{data: PlanCreate}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['createPlan'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createPlan>>, {data: PlanCreate}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return createPlan(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type CreatePlanMutationResult = NonNullable<Awaited<ReturnType<typeof createPlan>>>
|
||||||
|
export type CreatePlanMutationBody = PlanCreate
|
||||||
|
export type CreatePlanMutationError = HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Create Plan
|
||||||
|
*/
|
||||||
|
export const useCreatePlan = <TError = HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlan>>, TError,{data: PlanCreate}, TContext>, request?: SecondParameter<typeof customFetcher>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof createPlan>>,
|
||||||
|
TError,
|
||||||
|
{data: PlanCreate},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getCreatePlanMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
/**
|
||||||
* @summary Get Plan
|
* @summary Get Plan
|
||||||
*/
|
*/
|
||||||
export type getPlanResponse200 = {
|
export type getPlanResponse200 = {
|
||||||
data: GetPlan200
|
data: PlanResponse
|
||||||
status: 200
|
status: 200
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,17 +148,17 @@ export type getPlanResponseError = (getPlanResponse422) & {
|
||||||
|
|
||||||
export type getPlanResponse = (getPlanResponseSuccess | getPlanResponseError)
|
export type getPlanResponse = (getPlanResponseSuccess | getPlanResponseError)
|
||||||
|
|
||||||
export const getGetPlanUrl = (runId: string,) => {
|
export const getGetPlanUrl = (id: string,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return `/api/plans/${runId}`
|
return `/api/plans/${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPlan = async (runId: string, options?: RequestInit): Promise<getPlanResponse> => {
|
export const getPlan = async (id: string, options?: RequestInit): Promise<getPlanResponse> => {
|
||||||
|
|
||||||
return customFetcher<getPlanResponse>(getGetPlanUrl(runId),
|
return customFetcher<getPlanResponse>(getGetPlanUrl(id),
|
||||||
{
|
{
|
||||||
...options,
|
...options,
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
|
|
@ -76,29 +171,29 @@ export const getPlan = async (runId: string, options?: RequestInit): Promise<get
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getGetPlanQueryKey = (runId?: string,) => {
|
export const getGetPlanQueryKey = (id?: string,) => {
|
||||||
return [
|
return [
|
||||||
`/api/plans/${runId}`
|
`/api/plans/${id}`
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getGetPlanQueryOptions = <TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(runId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
|
export const getGetPlanQueryOptions = <TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
const queryKey = queryOptions?.queryKey ?? getGetPlanQueryKey(runId);
|
const queryKey = queryOptions?.queryKey ?? getGetPlanQueryKey(id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getPlan>>> = () => getPlan(runId, requestOptions);
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getPlan>>> = () => getPlan(id, requestOptions);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { queryKey, queryFn, enabled: !!(runId), staleTime: 60000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData> & { queryKey: DataTag<QueryKey, TData> }
|
return { queryKey, queryFn, enabled: !!(id), staleTime: 60000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData> & { queryKey: DataTag<QueryKey, TData> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetPlanQueryResult = NonNullable<Awaited<ReturnType<typeof getPlan>>>
|
export type GetPlanQueryResult = NonNullable<Awaited<ReturnType<typeof getPlan>>>
|
||||||
|
|
@ -106,7 +201,7 @@ export type GetPlanQueryError = HTTPValidationError
|
||||||
|
|
||||||
|
|
||||||
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
||||||
runId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>> & Pick<
|
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>> & Pick<
|
||||||
DefinedInitialDataOptions<
|
DefinedInitialDataOptions<
|
||||||
Awaited<ReturnType<typeof getPlan>>,
|
Awaited<ReturnType<typeof getPlan>>,
|
||||||
TError,
|
TError,
|
||||||
|
|
@ -116,7 +211,7 @@ export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError =
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
|
||||||
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
||||||
runId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>> & Pick<
|
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>> & Pick<
|
||||||
UndefinedInitialDataOptions<
|
UndefinedInitialDataOptions<
|
||||||
Awaited<ReturnType<typeof getPlan>>,
|
Awaited<ReturnType<typeof getPlan>>,
|
||||||
TError,
|
TError,
|
||||||
|
|
@ -126,7 +221,7 @@ export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError =
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
|
||||||
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
||||||
runId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
|
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
|
||||||
/**
|
/**
|
||||||
|
|
@ -134,11 +229,11 @@ export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError =
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
export function useGetPlan<TData = Awaited<ReturnType<typeof getPlan>>, TError = HTTPValidationError>(
|
||||||
runId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
|
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlan>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> } {
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> } {
|
||||||
|
|
||||||
const queryOptions = getGetPlanQueryOptions(runId,options)
|
const queryOptions = getGetPlanQueryOptions(id,options)
|
||||||
|
|
||||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> };
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ export const AnalysisStatus = {
|
||||||
discovering: 'discovering',
|
discovering: 'discovering',
|
||||||
collecting: 'collecting',
|
collecting: 'collecting',
|
||||||
analyzing: 'analyzing',
|
analyzing: 'analyzing',
|
||||||
planning: 'planning',
|
|
||||||
completed: 'completed',
|
completed: 'completed',
|
||||||
failed: 'failed',
|
failed: 'failed',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { AssetCardSource } from './assetCardSource';
|
|
||||||
import type { AssetCardType } from './assetCardType';
|
|
||||||
import type { AssetCardStatus } from './assetCardStatus';
|
|
||||||
|
|
||||||
export interface AssetCard {
|
|
||||||
id: string;
|
|
||||||
source: AssetCardSource;
|
|
||||||
sourceLabel: string;
|
|
||||||
type: AssetCardType;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
repurposingSuggestions: string[];
|
|
||||||
status: AssetCardStatus;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type AssetCardSource = typeof AssetCardSource[keyof typeof AssetCardSource];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const AssetCardSource = {
|
|
||||||
homepage: 'homepage',
|
|
||||||
naver_place: 'naver_place',
|
|
||||||
blog: 'blog',
|
|
||||||
social: 'social',
|
|
||||||
youtube: 'youtube',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type AssetCardStatus = typeof AssetCardStatus[keyof typeof AssetCardStatus];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const AssetCardStatus = {
|
|
||||||
collected: 'collected',
|
|
||||||
pending: 'pending',
|
|
||||||
needs_creation: 'needs_creation',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type AssetCardType = typeof AssetCardType[keyof typeof AssetCardType];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const AssetCardType = {
|
|
||||||
photo: 'photo',
|
|
||||||
video: 'video',
|
|
||||||
text: 'text',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { AssetCard } from './assetCard';
|
|
||||||
import type { YouTubeRepurposeItem } from './youTubeRepurposeItem';
|
|
||||||
|
|
||||||
export interface AssetCollectionData {
|
|
||||||
assets: AssetCard[];
|
|
||||||
youtubeRepurpose: YouTubeRepurposeItem[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { ColorSwatch } from './colorSwatch';
|
|
||||||
import type { FontSpec } from './fontSpec';
|
|
||||||
import type { LogoUsageRule } from './logoUsageRule';
|
|
||||||
import type { ToneOfVoice } from './toneOfVoice';
|
|
||||||
import type { ChannelBrandingRule } from './channelBrandingRule';
|
|
||||||
import type { BrandInconsistency } from './brandInconsistency';
|
|
||||||
|
|
||||||
export interface BrandGuide {
|
|
||||||
colors: ColorSwatch[];
|
|
||||||
fonts: FontSpec[];
|
|
||||||
logoRules: LogoUsageRule[];
|
|
||||||
toneOfVoice: ToneOfVoice;
|
|
||||||
channelBranding: ChannelBrandingRule[];
|
|
||||||
brandInconsistencies: BrandInconsistency[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { BrandInconsistencyValue } from './brandInconsistencyValue';
|
|
||||||
|
|
||||||
export interface BrandInconsistency {
|
|
||||||
field: string;
|
|
||||||
values: BrandInconsistencyValue[];
|
|
||||||
impact: string;
|
|
||||||
recommendation: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface BrandInconsistencyValue {
|
|
||||||
channel: string;
|
|
||||||
value: string;
|
|
||||||
isCorrect: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { CalendarWeek } from './calendarWeek';
|
|
||||||
import type { ContentCountSummary } from './contentCountSummary';
|
|
||||||
|
|
||||||
export interface CalendarData {
|
|
||||||
weeks: CalendarWeek[];
|
|
||||||
monthlySummary: ContentCountSummary[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { CalendarEntryContentType } from './calendarEntryContentType';
|
|
||||||
import type { CalendarEntryId } from './calendarEntryId';
|
|
||||||
import type { CalendarEntryDescription } from './calendarEntryDescription';
|
|
||||||
import type { CalendarEntryPillar } from './calendarEntryPillar';
|
|
||||||
import type { CalendarEntryStatus } from './calendarEntryStatus';
|
|
||||||
import type { CalendarEntryIsManualEdit } from './calendarEntryIsManualEdit';
|
|
||||||
import type { CalendarEntryAiPromptSeed } from './calendarEntryAiPromptSeed';
|
|
||||||
|
|
||||||
export interface CalendarEntry {
|
|
||||||
dayOfWeek: number;
|
|
||||||
channel: string;
|
|
||||||
channelIcon: string;
|
|
||||||
contentType: CalendarEntryContentType;
|
|
||||||
title: string;
|
|
||||||
id?: CalendarEntryId;
|
|
||||||
description?: CalendarEntryDescription;
|
|
||||||
pillar?: CalendarEntryPillar;
|
|
||||||
status?: CalendarEntryStatus;
|
|
||||||
isManualEdit?: CalendarEntryIsManualEdit;
|
|
||||||
aiPromptSeed?: CalendarEntryAiPromptSeed;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CalendarEntryContentType = typeof CalendarEntryContentType[keyof typeof CalendarEntryContentType];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const CalendarEntryContentType = {
|
|
||||||
video: 'video',
|
|
||||||
blog: 'blog',
|
|
||||||
social: 'social',
|
|
||||||
ad: 'ad',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CalendarEntryIsManualEdit = boolean | null;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CalendarEntryPillar = string | null;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type CalendarEntryStatus = 'draft' | 'approved' | 'published' | null;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { CalendarEntry } from './calendarEntry';
|
|
||||||
|
|
||||||
export interface CalendarWeek {
|
|
||||||
weekNumber: number;
|
|
||||||
label: string;
|
|
||||||
entries: CalendarEntry[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { ChannelBrandingRuleCurrentStatus } from './channelBrandingRuleCurrentStatus';
|
|
||||||
|
|
||||||
export interface ChannelBrandingRule {
|
|
||||||
channel: string;
|
|
||||||
icon: string;
|
|
||||||
profilePhoto: string;
|
|
||||||
bannerSpec: string;
|
|
||||||
bioTemplate: string;
|
|
||||||
currentStatus: ChannelBrandingRuleCurrentStatus;
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ChannelBrandingRuleCurrentStatus = typeof ChannelBrandingRuleCurrentStatus[keyof typeof ChannelBrandingRuleCurrentStatus];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const ChannelBrandingRuleCurrentStatus = {
|
|
||||||
correct: 'correct',
|
|
||||||
incorrect: 'incorrect',
|
|
||||||
missing: 'missing',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { ChannelStrategyCardPriority } from './channelStrategyCardPriority';
|
|
||||||
import type { ChannelStrategyCardCustomerJourneyStage } from './channelStrategyCardCustomerJourneyStage';
|
|
||||||
|
|
||||||
export interface ChannelStrategyCard {
|
|
||||||
channelId: string;
|
|
||||||
channelName: string;
|
|
||||||
icon: string;
|
|
||||||
currentStatus: string;
|
|
||||||
targetGoal: string;
|
|
||||||
contentTypes: string[];
|
|
||||||
postingFrequency: string;
|
|
||||||
tone: string;
|
|
||||||
formatGuidelines: string[];
|
|
||||||
priority: ChannelStrategyCardPriority;
|
|
||||||
customerJourneyStage?: ChannelStrategyCardCustomerJourneyStage;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ChannelStrategyCardCustomerJourneyStage = 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty' | null;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ChannelStrategyCardPriority = typeof ChannelStrategyCardPriority[keyof typeof ChannelStrategyCardPriority];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const ChannelStrategyCardPriority = {
|
|
||||||
P0: 'P0',
|
|
||||||
P1: 'P1',
|
|
||||||
P2: 'P2',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ColorSwatch {
|
|
||||||
name: string;
|
|
||||||
hex: string;
|
|
||||||
usage: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { ContentCountSummaryType } from './contentCountSummaryType';
|
|
||||||
|
|
||||||
export interface ContentCountSummary {
|
|
||||||
type: ContentCountSummaryType;
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ContentCountSummaryType = typeof ContentCountSummaryType[keyof typeof ContentCountSummaryType];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const ContentCountSummaryType = {
|
|
||||||
video: 'video',
|
|
||||||
blog: 'blog',
|
|
||||||
social: 'social',
|
|
||||||
ad: 'ad',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ContentPillar {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
relatedUSP: string;
|
|
||||||
exampleTopics: string[];
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { ContentPillar } from './contentPillar';
|
|
||||||
import type { ContentTypeRow } from './contentTypeRow';
|
|
||||||
import type { WorkflowStep } from './workflowStep';
|
|
||||||
import type { RepurposingOutput } from './repurposingOutput';
|
|
||||||
|
|
||||||
export interface ContentStrategyData {
|
|
||||||
pillars: ContentPillar[];
|
|
||||||
typeMatrix: ContentTypeRow[];
|
|
||||||
workflow: WorkflowStep[];
|
|
||||||
repurposingSource: string;
|
|
||||||
repurposingOutputs: RepurposingOutput[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ContentTypeRow {
|
|
||||||
format: string;
|
|
||||||
channels: string[];
|
|
||||||
frequency: string;
|
|
||||||
purpose: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface FontSpec {
|
|
||||||
family: string;
|
|
||||||
weight: string;
|
|
||||||
usage: string;
|
|
||||||
sampleText: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { PlanOutput } from './planOutput';
|
|
||||||
|
|
||||||
export type GetPlan200 = PlanOutput | null;
|
|
||||||
|
|
@ -12,30 +12,7 @@ export * from './analysisStatus';
|
||||||
export * from './analysisStatusResponse';
|
export * from './analysisStatusResponse';
|
||||||
export * from './analysisStatusResponseChannelErrors';
|
export * from './analysisStatusResponseChannelErrors';
|
||||||
export * from './analysisStatusResponseCompletedAt';
|
export * from './analysisStatusResponseCompletedAt';
|
||||||
export * from './assetCard';
|
|
||||||
export * from './assetCardSource';
|
|
||||||
export * from './assetCardStatus';
|
|
||||||
export * from './assetCardType';
|
|
||||||
export * from './assetCollectionData';
|
|
||||||
export * from './brandGuide';
|
|
||||||
export * from './brandInconsistency';
|
|
||||||
export * from './brandInconsistencyValue';
|
|
||||||
export * from './calendarData';
|
|
||||||
export * from './calendarEntry';
|
|
||||||
export * from './calendarEntryAiPromptSeed';
|
|
||||||
export * from './calendarEntryContentType';
|
|
||||||
export * from './calendarEntryDescription';
|
|
||||||
export * from './calendarEntryId';
|
|
||||||
export * from './calendarEntryIsManualEdit';
|
|
||||||
export * from './calendarEntryPillar';
|
|
||||||
export * from './calendarEntryStatus';
|
|
||||||
export * from './calendarWeek';
|
|
||||||
export * from './channelBrandingRule';
|
|
||||||
export * from './channelBrandingRuleCurrentStatus';
|
|
||||||
export * from './channelScore';
|
export * from './channelScore';
|
||||||
export * from './channelStrategyCard';
|
|
||||||
export * from './channelStrategyCardCustomerJourneyStage';
|
|
||||||
export * from './channelStrategyCardPriority';
|
|
||||||
export * from './channels';
|
export * from './channels';
|
||||||
export * from './channelsFacebook';
|
export * from './channelsFacebook';
|
||||||
export * from './channelsGangnamUnni';
|
export * from './channelsGangnamUnni';
|
||||||
|
|
@ -52,37 +29,22 @@ export * from './clinicResponseRawData';
|
||||||
export * from './clinicResponseRawDataAnyOf';
|
export * from './clinicResponseRawDataAnyOf';
|
||||||
export * from './clinicResponseRoadAddress';
|
export * from './clinicResponseRoadAddress';
|
||||||
export * from './clinicResponseUrl';
|
export * from './clinicResponseUrl';
|
||||||
export * from './colorSwatch';
|
|
||||||
export * from './contentCountSummary';
|
|
||||||
export * from './contentCountSummaryType';
|
|
||||||
export * from './contentPillar';
|
|
||||||
export * from './contentStrategyData';
|
|
||||||
export * from './contentTypeRow';
|
|
||||||
export * from './conversionStrategy';
|
export * from './conversionStrategy';
|
||||||
export * from './fontSpec';
|
|
||||||
export * from './getPlan200';
|
|
||||||
export * from './getReport200';
|
export * from './getReport200';
|
||||||
export * from './hTTPValidationError';
|
export * from './hTTPValidationError';
|
||||||
export * from './logoUsageRule';
|
export * from './planCreate';
|
||||||
export * from './planOutput';
|
export * from './planResponse';
|
||||||
export * from './planOutputRepurposingProposals';
|
export * from './planResponseBrandGuide';
|
||||||
|
export * from './planResponseContentStrategy';
|
||||||
export * from './reportOutput';
|
export * from './reportOutput';
|
||||||
export * from './reportOutputFacebook';
|
export * from './reportOutputFacebook';
|
||||||
export * from './reportOutputGangnamUnni';
|
export * from './reportOutputGangnamUnni';
|
||||||
export * from './reportOutputInstagram';
|
export * from './reportOutputInstagram';
|
||||||
export * from './reportOutputNaverBlog';
|
export * from './reportOutputNaverBlog';
|
||||||
export * from './reportOutputYoutube';
|
export * from './reportOutputYoutube';
|
||||||
export * from './repurposingOutput';
|
|
||||||
export * from './repurposingProposalItem';
|
|
||||||
export * from './repurposingProposalItemEstimatedEffort';
|
|
||||||
export * from './repurposingProposalItemPriority';
|
|
||||||
export * from './runSummary';
|
export * from './runSummary';
|
||||||
export * from './runSummaryCompletedAt';
|
export * from './runSummaryCompletedAt';
|
||||||
export * from './runSummaryOverallScore';
|
export * from './runSummaryOverallScore';
|
||||||
export * from './toneOfVoice';
|
|
||||||
export * from './validationError';
|
export * from './validationError';
|
||||||
export * from './validationErrorCtx';
|
export * from './validationErrorCtx';
|
||||||
export * from './validationErrorLocItem';
|
export * from './validationErrorLocItem';
|
||||||
export * from './workflowStep';
|
|
||||||
export * from './youTubeRepurposeItem';
|
|
||||||
export * from './youTubeRepurposeItemType';
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface LogoUsageRule {
|
|
||||||
rule: string;
|
|
||||||
description: string;
|
|
||||||
correct: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -5,4 +5,7 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CalendarEntryId = string | null;
|
export interface PlanCreate {
|
||||||
|
report_id: string;
|
||||||
|
regenerate?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { BrandGuide } from './brandGuide';
|
|
||||||
import type { ChannelStrategyCard } from './channelStrategyCard';
|
|
||||||
import type { ContentStrategyData } from './contentStrategyData';
|
|
||||||
import type { CalendarData } from './calendarData';
|
|
||||||
import type { AssetCollectionData } from './assetCollectionData';
|
|
||||||
import type { PlanOutputRepurposingProposals } from './planOutputRepurposingProposals';
|
|
||||||
|
|
||||||
export interface PlanOutput {
|
|
||||||
brandGuide: BrandGuide;
|
|
||||||
channelStrategies: ChannelStrategyCard[];
|
|
||||||
contentStrategy: ContentStrategyData;
|
|
||||||
calendar: CalendarData;
|
|
||||||
assetCollection: AssetCollectionData;
|
|
||||||
repurposingProposals?: PlanOutputRepurposingProposals;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { RepurposingProposalItem } from './repurposingProposalItem';
|
|
||||||
|
|
||||||
export type PlanOutputRepurposingProposals = RepurposingProposalItem[] | null;
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* FastAPI
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { PlanResponseBrandGuide } from './planResponseBrandGuide';
|
||||||
|
import type { PlanResponseContentStrategy } from './planResponseContentStrategy';
|
||||||
|
|
||||||
|
export interface PlanResponse {
|
||||||
|
id: string;
|
||||||
|
analysis_run_id: string;
|
||||||
|
brand_guide: PlanResponseBrandGuide;
|
||||||
|
channel_strategies: unknown[];
|
||||||
|
content_strategy: PlanResponseContentStrategy;
|
||||||
|
calendar: unknown[];
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CalendarEntryDescription = string | null;
|
export type PlanResponseBrandGuide = { [key: string]: unknown };
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CalendarEntryAiPromptSeed = string | null;
|
export type PlanResponseContentStrategy = { [key: string]: unknown };
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface RepurposingOutput {
|
|
||||||
format: string;
|
|
||||||
channel: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { YouTubeRepurposeItem } from './youTubeRepurposeItem';
|
|
||||||
import type { RepurposingOutput } from './repurposingOutput';
|
|
||||||
import type { RepurposingProposalItemEstimatedEffort } from './repurposingProposalItemEstimatedEffort';
|
|
||||||
import type { RepurposingProposalItemPriority } from './repurposingProposalItemPriority';
|
|
||||||
|
|
||||||
export interface RepurposingProposalItem {
|
|
||||||
sourceVideo: YouTubeRepurposeItem;
|
|
||||||
outputs: RepurposingOutput[];
|
|
||||||
estimatedEffort: RepurposingProposalItemEstimatedEffort;
|
|
||||||
priority: RepurposingProposalItemPriority;
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type RepurposingProposalItemEstimatedEffort = typeof RepurposingProposalItemEstimatedEffort[keyof typeof RepurposingProposalItemEstimatedEffort];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const RepurposingProposalItemEstimatedEffort = {
|
|
||||||
low: 'low',
|
|
||||||
medium: 'medium',
|
|
||||||
high: 'high',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type RepurposingProposalItemPriority = typeof RepurposingProposalItemPriority[keyof typeof RepurposingProposalItemPriority];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const RepurposingProposalItemPriority = {
|
|
||||||
high: 'high',
|
|
||||||
medium: 'medium',
|
|
||||||
low: 'low',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ToneOfVoice {
|
|
||||||
personality: string[];
|
|
||||||
communicationStyle: string;
|
|
||||||
doExamples: string[];
|
|
||||||
dontExamples: string[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface WorkflowStep {
|
|
||||||
step: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
owner: string;
|
|
||||||
duration: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
import type { YouTubeRepurposeItemType } from './youTubeRepurposeItemType';
|
|
||||||
|
|
||||||
export interface YouTubeRepurposeItem {
|
|
||||||
title: string;
|
|
||||||
views: number;
|
|
||||||
type: YouTubeRepurposeItemType;
|
|
||||||
repurposeAs: string[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
/**
|
|
||||||
* Generated by orval v7.21.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* FastAPI
|
|
||||||
* OpenAPI spec version: 0.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type YouTubeRepurposeItemType = typeof YouTubeRepurposeItemType[keyof typeof YouTubeRepurposeItemType];
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
||||||
export const YouTubeRepurposeItemType = {
|
|
||||||
Short: 'Short',
|
|
||||||
Long: 'Long',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -8,7 +8,3 @@ interface ImportMetaEnv {
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
// vite.config.ts `define` 로 빌드 시 치환되는 글로벌 — api 모듈에서 import.meta 회피용
|
|
||||||
declare const __VITE_API_BASE_URL__: string;
|
|
||||||
declare const __VITE_API_KEY__: string;
|
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,6 @@ export default defineConfig(({ mode }) => {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// src/shared/api/api.ts 가 orval 의 mutator 로 cjs 번들될 때 `import.meta` 가 비어
|
|
||||||
// 경고가 뜨던 문제를 우회. Vite 가 빌드 타임에 아래 글로벌을 실값으로 치환한다.
|
|
||||||
define: {
|
|
||||||
__VITE_API_BASE_URL__: JSON.stringify(env.VITE_API_BASE_URL ?? ''),
|
|
||||||
__VITE_API_KEY__: JSON.stringify(env.VITE_API_KEY ?? ''),
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue