메인페이지 원복시키기
parent
c07ef255d4
commit
5f7e0051cd
|
|
@ -31,7 +31,6 @@ import {
|
||||||
pickPrimaryUrl,
|
pickPrimaryUrl,
|
||||||
type ClassifiedUrls,
|
type ClassifiedUrls,
|
||||||
} from '../lib/classifyUrls';
|
} from '../lib/classifyUrls';
|
||||||
import { Button } from '@/shared/ui/button';
|
|
||||||
import { PLATFORM_META } from '@/features/clinics/components/PlatformChips';
|
import { PLATFORM_META } from '@/features/clinics/components/PlatformChips';
|
||||||
import type { PlatformKey } from '@/features/clinics/types/workspace';
|
import type { PlatformKey } from '@/features/clinics/types/workspace';
|
||||||
|
|
||||||
|
|
@ -159,15 +158,13 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
||||||
const Icon = meta.Icon;
|
const Icon = meta.Icon;
|
||||||
const value = urls[key];
|
const value = urls[key];
|
||||||
const status = validateField(value, key);
|
const status = validateField(value, key);
|
||||||
const inactiveColor = isHero ? '#94a3b8' /* slate-400 */ : 'rgba(255,255,255,0.4)';
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="relative">
|
<div key={key} className="relative">
|
||||||
{/* 좌측 채널 아이콘 — 입력 시 브랜드 컬러, 빈 칸일 땐 회색.
|
{/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */}
|
||||||
z-10 으로 input 의 bg/backdrop-blur 위에 올림 (없으면 input 배경이 아이콘을 가림) */}
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2">
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2 z-10">
|
|
||||||
<Icon
|
<Icon
|
||||||
size={16}
|
size={16}
|
||||||
style={{ color: value ? meta.color : inactiveColor }}
|
style={{ color: value ? meta.color : isHero ? '#94a3b8' : 'rgba(255,255,255,0.4)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -181,8 +178,8 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{/* 우측 검증 상태 아이콘 — 동일하게 z-10 */}
|
{/* 우측 검증 상태 아이콘 */}
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
{status === 'valid' && (
|
{status === 'valid' && (
|
||||||
<CheckFilled size={16} className="text-emerald-500" />
|
<CheckFilled size={16} className="text-emerald-500" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -201,18 +198,18 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 분석 시작 버튼 — DS Primary pill */}
|
{/* 분석 시작 버튼 — DS Primary pill */}
|
||||||
<Button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canAnalyze}
|
disabled={!canAnalyze}
|
||||||
className={`w-full max-w-md mx-auto mt-4 px-10 py-4 h-auto text-lg font-medium rounded-full shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r transform-gpu will-change-transform transition-[transform,filter,box-shadow] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:hover:scale-[1.02] motion-safe:hover:brightness-110 active:scale-[0.98] active:duration-150 ${
|
className={`w-full max-w-md mx-auto mt-4 px-10 py-4 text-lg font-medium rounded-full transition-all duration-150 shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r active:scale-[0.98] ${
|
||||||
isHero
|
isHero
|
||||||
? 'from-brand-purple to-brand-purple-deep text-white'
|
? 'from-[#4F1DA1] to-[#021341] text-white hover:from-[#AF90FF] hover:to-[#AF90FF]'
|
||||||
: 'from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky text-primary-900'
|
: 'from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] text-primary-900 hover:scale-[1.02]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Analyze
|
Analyze
|
||||||
<ArrowRight className="size-5 transform-gpu will-change-transform transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:group-hover:translate-x-1" />
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1" />
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
{/* 보조 안내 (하단) */}
|
{/* 보조 안내 (하단) */}
|
||||||
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${helperClass}`}>
|
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${helperClass}`}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useGetClinic, useGetClinicHistory } from '@/shared/api/generated/clinics/clinics';
|
||||||
|
import { AnalysisStatus } from '@/shared/api/model/analysisStatus';
|
||||||
|
import type {
|
||||||
|
WorkspaceData,
|
||||||
|
WorkspaceRun,
|
||||||
|
WorkspaceRunStatus,
|
||||||
|
} from '../types/workspace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /clinics/:clinicId 워크스페이스용 데이터 통합 훅.
|
||||||
|
*
|
||||||
|
* - useGetClinic: 클리닉 기본 정보 (hospital_name, hospital_name_en 등)
|
||||||
|
* - useGetClinicHistory: 분석 run 목록 + 시계열 지표
|
||||||
|
*
|
||||||
|
* 백엔드에 plans 엔드포인트가 아직 없어 plans 는 빈 배열로 둠.
|
||||||
|
* (필요해지면 useGetPlan / 별도 list 엔드포인트 추가 시 합쳐 채워넣기)
|
||||||
|
*/
|
||||||
|
export function useClinicWorkspace(clinicId: string | undefined) {
|
||||||
|
const clinicQuery = useGetClinic(clinicId ?? '', {
|
||||||
|
query: { enabled: !!clinicId },
|
||||||
|
});
|
||||||
|
const historyQuery = useGetClinicHistory(clinicId ?? '', {
|
||||||
|
query: { enabled: !!clinicId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = useMemo<WorkspaceData | null>(() => {
|
||||||
|
if (!clinicId) return null;
|
||||||
|
if (clinicQuery.data?.status !== 200) return null;
|
||||||
|
if (historyQuery.data?.status !== 200) return null;
|
||||||
|
|
||||||
|
const clinic = clinicQuery.data.data;
|
||||||
|
const history = historyQuery.data.data;
|
||||||
|
|
||||||
|
const runs: WorkspaceRun[] = history.runs.map((r) => ({
|
||||||
|
runId: r.run_id,
|
||||||
|
startedAt: r.started_at,
|
||||||
|
completedAt: r.completed_at ?? null,
|
||||||
|
status: normalizeRunStatus(r.status),
|
||||||
|
overallScore: typeof r.overall_score === 'number' ? r.overall_score : null,
|
||||||
|
// 백엔드에 분석 대상 targets 가 아직 없음 — 빈 배열
|
||||||
|
targets: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
clinic: {
|
||||||
|
clinicId: clinic.hospital_id,
|
||||||
|
name: clinic.hospital_name,
|
||||||
|
nameEn: clinic.hospital_name_en ?? undefined,
|
||||||
|
location: clinic.road_address ?? undefined,
|
||||||
|
defaultTargets: [],
|
||||||
|
},
|
||||||
|
runs,
|
||||||
|
plans: [],
|
||||||
|
};
|
||||||
|
}, [clinicId, clinicQuery.data, historyQuery.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading: clinicQuery.isLoading || historyQuery.isLoading,
|
||||||
|
error:
|
||||||
|
clinicQuery.error?.message ||
|
||||||
|
historyQuery.error?.message ||
|
||||||
|
(clinicQuery.data && clinicQuery.data.status !== 200 ? '클리닉 정보를 불러올 수 없습니다.' : null) ||
|
||||||
|
(historyQuery.data && historyQuery.data.status !== 200 ? '분석 이력을 불러올 수 없습니다.' : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRunStatus(s: string): WorkspaceRunStatus {
|
||||||
|
if (s === AnalysisStatus.completed) return 'completed';
|
||||||
|
if (s === AnalysisStatus.failed) return 'failed';
|
||||||
|
if (s === 'queued') return 'queued';
|
||||||
|
return 'running';
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs';
|
||||||
import { WorkspaceHeader } from '../components/WorkspaceHeader';
|
import { WorkspaceHeader } from '../components/WorkspaceHeader';
|
||||||
import { AnalysisTab } from '../components/tabs/AnalysisTab';
|
import { AnalysisTab } from '../components/tabs/AnalysisTab';
|
||||||
import { SettingsTab } from '../components/tabs/SettingsTab';
|
import { SettingsTab } from '../components/tabs/SettingsTab';
|
||||||
import { mockWorkspace } from '../data/mockClinicWorkspace';
|
import { useClinicWorkspace } from '../hooks/useClinicWorkspace';
|
||||||
import { PageContainer } from '@/shared/ui/page-container';
|
import { PageContainer } from '@/shared/ui/page-container';
|
||||||
|
|
||||||
const VALID_TABS = ['analysis', 'settings'] as const;
|
const VALID_TABS = ['analysis', 'settings'] as const;
|
||||||
|
|
@ -31,24 +31,16 @@ export default function ClinicWorkspacePage() {
|
||||||
const requested = searchParams.get('tab');
|
const requested = searchParams.get('tab');
|
||||||
const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis';
|
const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis';
|
||||||
|
|
||||||
// TODO: 실 API 연동 (useGetClinicHistory + report 메타 보강)
|
const { data, isLoading, error } = useClinicWorkspace(clinicId);
|
||||||
const data = useMemo(
|
|
||||||
() => ({
|
|
||||||
...mockWorkspace,
|
|
||||||
clinic: {
|
|
||||||
...mockWorkspace.clinic,
|
|
||||||
clinicId: clinicId ?? mockWorkspace.clinic.clinicId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[clinicId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortedRuns = useMemo(
|
const sortedRuns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...data.runs].sort(
|
data
|
||||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
? [...data.runs].sort(
|
||||||
),
|
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||||
[data.runs],
|
)
|
||||||
|
: [],
|
||||||
|
[data],
|
||||||
);
|
);
|
||||||
const latestRun = sortedRuns[0];
|
const latestRun = sortedRuns[0];
|
||||||
const previousRun = sortedRuns[1];
|
const previousRun = sortedRuns[1];
|
||||||
|
|
@ -79,6 +71,28 @@ export default function ClinicWorkspacePage() {
|
||||||
window.scrollTo({ top: y, behavior: 'auto' });
|
window.scrollTo({ top: y, behavior: 'auto' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pt-20 min-h-screen flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-slate-500 text-sm">워크스페이스를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="pt-20 min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[#7C3A4B] font-medium mb-2">오류가 발생했습니다</p>
|
||||||
|
<p className="text-slate-500 text-sm">{error ?? '워크스페이스 데이터를 찾을 수 없습니다.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-20 flex-1 flex flex-col">
|
<div className="pt-20 flex-1 flex flex-col">
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export default function CTA() {
|
||||||
>
|
>
|
||||||
<MultiChannelInput variant="cta" onAnalyze={handleAnalyze} />
|
<MultiChannelInput variant="cta" onAnalyze={handleAnalyze} />
|
||||||
|
|
||||||
{/* 보조 CTA — 가격 플랜 (임시 비활성)
|
{/* 보조 CTA — 가격 플랜 */}
|
||||||
<div className="mt-6 flex justify-center">
|
<div className="mt-6 flex justify-center">
|
||||||
<Link
|
<Link
|
||||||
to="/pricing?from=cta"
|
to="/pricing?from=cta"
|
||||||
|
|
@ -68,7 +68,6 @@ export default function CTA() {
|
||||||
가격 플랜 보기 →
|
가격 플랜 보기 →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
*/}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,25 @@ 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/analyses/analyses';
|
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses';
|
||||||
import { getReport } from '@/shared/api/generated/reports/reports';
|
import { AnalysisStatus } from '@/shared/api/model/analysisStatus';
|
||||||
|
import { sleep } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
// AnalyzePayload(MultiChannelInput) 와 호환되도록 string | string[] 둘 다 허용.
|
||||||
|
// 백엔드 SDK 는 단일 string 만 받으므로 내부에서 첫 요소만 추출.
|
||||||
export type ManualChannels = {
|
export type ManualChannels = {
|
||||||
instagram?: string;
|
instagram?: string | string[];
|
||||||
youtube?: string;
|
youtube?: string | string[];
|
||||||
facebook?: string;
|
facebook?: string | string[];
|
||||||
naverBlog?: string;
|
naverBlog?: string | string[];
|
||||||
gangnamUnni?: string;
|
gangnamUnni?: string | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function pickFirst(v: string | string[] | undefined): string | null {
|
||||||
|
if (!v) return null;
|
||||||
|
if (Array.isArray(v)) return v[0] ?? null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
export type Phase =
|
export type Phase =
|
||||||
| 'resuming'
|
| 'resuming'
|
||||||
| 'discovering'
|
| 'discovering'
|
||||||
|
|
@ -47,22 +56,9 @@ function clearSession() {
|
||||||
Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k));
|
Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 백엔드 AnalysisStatus enum: discovering | collecting | analyzing | completed | failed
|
// 백엔드 AnalysisStatus 4단계를 UI 5단계로 매핑.
|
||||||
// UI Phase 스텝과 매핑
|
// analyzing 한 단계는 길어서 generating → planning 으로 시간 기반 분할.
|
||||||
function phaseFromStatus(s: string): Phase {
|
const ANALYZING_SPLIT_MS = 30_000;
|
||||||
if (s === 'discovering') return 'collecting';
|
|
||||||
if (s === 'collecting') return 'generating';
|
|
||||||
if (s === 'analyzing') return 'planning';
|
|
||||||
if (s === 'completed') return 'complete';
|
|
||||||
return 'discovering';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resumePhaseFromStatus(s: string): Phase {
|
|
||||||
if (s === 'discovering') return 'collecting';
|
|
||||||
if (s === 'collecting') return 'generating';
|
|
||||||
if (s === 'analyzing') return 'planning';
|
|
||||||
return 'discovering';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAnalysisPipelineResult {
|
interface UseAnalysisPipelineResult {
|
||||||
phase: Phase;
|
phase: Phase;
|
||||||
|
|
@ -98,13 +94,26 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
const runPipeline = useCallback(
|
const runPipeline = useCallback(
|
||||||
async (
|
async (
|
||||||
startUrl?: string,
|
startUrl?: string,
|
||||||
resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase },
|
resumeFrom?: { reportId: string; clinicId?: string; runId?: string },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
let reportId = resumeFrom?.reportId || '';
|
let reportId = resumeFrom?.reportId || '';
|
||||||
let clinicId = resumeFrom?.clinicId;
|
let clinicId = resumeFrom?.clinicId;
|
||||||
let runId = resumeFrom?.runId;
|
let runId = resumeFrom?.runId;
|
||||||
|
|
||||||
|
// analyzing 진입 시각을 기록해서 generating → planning 시점을 시간으로 판단
|
||||||
|
let analyzingStartedAt: number | null = null;
|
||||||
|
const mapPhase = (s: AnalysisStatus): Phase => {
|
||||||
|
if (s === AnalysisStatus.discovering) return 'discovering';
|
||||||
|
if (s === AnalysisStatus.collecting) return 'collecting';
|
||||||
|
if (s === AnalysisStatus.analyzing) {
|
||||||
|
if (analyzingStartedAt === null) analyzingStartedAt = Date.now();
|
||||||
|
return Date.now() - analyzingStartedAt > ANALYZING_SPLIT_MS ? 'planning' : 'generating';
|
||||||
|
}
|
||||||
|
if (s === AnalysisStatus.completed) return 'complete';
|
||||||
|
return 'discovering';
|
||||||
|
};
|
||||||
|
|
||||||
// Phase 1: 신규 분석이면 createClinic → startAnalysis
|
// Phase 1: 신규 분석이면 createClinic → startAnalysis
|
||||||
if (!resumeFrom?.runId) {
|
if (!resumeFrom?.runId) {
|
||||||
if (!startUrl) throw new Error('No URL provided');
|
if (!startUrl) throw new Error('No URL provided');
|
||||||
|
|
@ -120,11 +129,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
data: {
|
data: {
|
||||||
clinic_id: clinicId,
|
clinic_id: clinicId,
|
||||||
channels: {
|
channels: {
|
||||||
youtube: manualChannels?.youtube ?? null,
|
youtube: pickFirst(manualChannels?.youtube),
|
||||||
instagram: manualChannels?.instagram ?? null,
|
instagram: pickFirst(manualChannels?.instagram),
|
||||||
facebook: manualChannels?.facebook ?? null,
|
facebook: pickFirst(manualChannels?.facebook),
|
||||||
naver_blog: manualChannels?.naverBlog ?? null,
|
naver_blog: pickFirst(manualChannels?.naverBlog),
|
||||||
gangnam_unni: manualChannels?.gangnamUnni ?? null,
|
gangnam_unni: pickFirst(manualChannels?.gangnamUnni),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -146,40 +155,20 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
const statusRes = await getAnalysisStatus(runId);
|
const statusRes = await getAnalysisStatus(runId);
|
||||||
if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.');
|
if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.');
|
||||||
const statusData = statusRes.data;
|
const statusData = statusRes.data;
|
||||||
setPhase(phaseFromStatus(statusData.status));
|
setPhase(mapPhase(statusData.status));
|
||||||
if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) {
|
if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) {
|
||||||
console.warn('[pipeline] Partial failures:', statusData.channel_errors);
|
console.warn('[pipeline] Partial failures:', statusData.channel_errors);
|
||||||
}
|
}
|
||||||
if (statusData.status === 'completed') break;
|
if (statusData.status === AnalysisStatus.completed) break;
|
||||||
if (statusData.status === 'failed') {
|
if (statusData.status === AnalysisStatus.failed) {
|
||||||
throw new Error('파이프라인 실패: failed');
|
throw new Error('파이프라인 실패: failed');
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await sleep(1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPhase('complete');
|
setPhase('complete');
|
||||||
clearSession();
|
clearSession();
|
||||||
|
navigate(`/report/${reportId}`, { replace: true });
|
||||||
// 최종 리포트 조회 (best effort)
|
|
||||||
const reportRes = await getReport(reportId).catch(() => null);
|
|
||||||
const reportPayload = reportRes && reportRes.status === 200 ? reportRes.data : null;
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate(`/report/${reportId}`, {
|
|
||||||
replace: true,
|
|
||||||
state: reportPayload
|
|
||||||
? {
|
|
||||||
report: reportPayload,
|
|
||||||
metadata: {
|
|
||||||
url: startUrl,
|
|
||||||
clinicName: '',
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
reportId,
|
|
||||||
clinicId,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}, 800);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'An error occurred';
|
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
|
|
@ -203,13 +192,12 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
reportId: session.reportId,
|
reportId: session.reportId,
|
||||||
clinicId: session.clinicId || undefined,
|
clinicId: session.clinicId || undefined,
|
||||||
runId: session.runId || undefined,
|
runId: session.runId || undefined,
|
||||||
phase,
|
|
||||||
});
|
});
|
||||||
} else if (url || session.url) {
|
} else if (url || session.url) {
|
||||||
hasStarted.current = false;
|
hasStarted.current = false;
|
||||||
runPipeline(url || session.url || undefined);
|
runPipeline(url || session.url || undefined);
|
||||||
}
|
}
|
||||||
}, [phase, url, runPipeline]);
|
}, [url, runPipeline]);
|
||||||
|
|
||||||
const handleAbort = useCallback(() => {
|
const handleAbort = useCallback(() => {
|
||||||
clearSession();
|
clearSession();
|
||||||
|
|
@ -229,11 +217,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status !== 200) throw new Error('status fetch failed');
|
if (res.status !== 200) throw new Error('status fetch failed');
|
||||||
const status = res.data;
|
const status = res.data;
|
||||||
if (status.status === 'completed') {
|
if (status.status === AnalysisStatus.completed) {
|
||||||
navigate(`/report/${urlReportId}`, { replace: true });
|
navigate(`/report/${urlReportId}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status.status === 'failed') {
|
if (status.status === AnalysisStatus.failed) {
|
||||||
setError('Data collection failed. Please retry.');
|
setError('Data collection failed. Please retry.');
|
||||||
setPhase('collecting');
|
setPhase('collecting');
|
||||||
return;
|
return;
|
||||||
|
|
@ -248,7 +236,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
reportId: urlReportId,
|
reportId: urlReportId,
|
||||||
clinicId: session.clinicId || undefined,
|
clinicId: session.clinicId || undefined,
|
||||||
runId,
|
runId,
|
||||||
phase: resumePhaseFromStatus(status.status),
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
@ -266,7 +253,7 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status !== 200) throw new Error('status fetch failed');
|
if (res.status !== 200) throw new Error('status fetch failed');
|
||||||
const status = res.data;
|
const status = res.data;
|
||||||
if (status.status === 'completed') {
|
if (status.status === AnalysisStatus.completed) {
|
||||||
clearSession();
|
clearSession();
|
||||||
navigate(`/report/${session.reportId}`, { replace: true });
|
navigate(`/report/${session.reportId}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
|
|
@ -275,7 +262,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
||||||
reportId: session.reportId!,
|
reportId: session.reportId!,
|
||||||
clinicId: session.clinicId || undefined,
|
clinicId: session.clinicId || undefined,
|
||||||
runId,
|
runId,
|
||||||
phase: resumePhaseFromStatus(status.status),
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,7 @@
|
||||||
*/
|
*/
|
||||||
import ky, { type KyInstance } from 'ky'
|
import ky, { type KyInstance } from 'ky'
|
||||||
|
|
||||||
// Vite 가 빌드 시 치환하는 환경변수 (개발 서버에서도 동일).
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||||
// import.meta 를 모듈 최상단에서 읽으면 일부 번들러가 cjs 변환 시 경고를 내므로
|
|
||||||
// 함수 내부에서 lazy 하게 접근.
|
|
||||||
function getApiKey(): string | undefined {
|
|
||||||
try {
|
|
||||||
return (import.meta as { env?: { VITE_API_KEY?: string } }).env?.VITE_API_KEY;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const kyInstance: KyInstance = ky.create({
|
export const kyInstance: KyInstance = ky.create({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
|
|
@ -27,7 +18,7 @@ export const kyInstance: KyInstance = ky.create({
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
(request) => {
|
(request) => {
|
||||||
const apiKey = getApiKey();
|
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: 인증 토큰 주입
|
||||||
|
|
@ -46,7 +37,10 @@ export const customFetcher = async <T>(
|
||||||
url: string,
|
url: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const response = await kyInstance(url, init as Parameters<KyInstance>[1])
|
// orval 이 생성하는 url 은 `/api/...` 상대경로라서, 그대로 두면 dev 서버 origin 으로 감.
|
||||||
|
// VITE_API_BASE_URL 이 있으면 절대 URL 로 만들어서 백엔드로 직접 요청.
|
||||||
|
const fullUrl = /^https?:\/\//.test(url) ? url : `${API_BASE_URL}${url}`
|
||||||
|
const response = await kyInstance(fullUrl, init as Parameters<KyInstance>[1])
|
||||||
let data: unknown = null
|
let data: unknown = null
|
||||||
try {
|
try {
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|
|
||||||
|
|
@ -39,26 +39,12 @@ export default function Navbar() {
|
||||||
<a href="/#home" className="hover:text-primary-900 transition-colors">
|
<a href="/#home" className="hover:text-primary-900 transition-colors">
|
||||||
Product
|
Product
|
||||||
</a>
|
</a>
|
||||||
<a href="/#audience" className="hover:text-primary-900 transition-colors">
|
|
||||||
Audience
|
|
||||||
</a>
|
|
||||||
<a href="/#problems" className="hover:text-primary-900 transition-colors">
|
|
||||||
Problems
|
|
||||||
</a>
|
|
||||||
<a href="/#solution" className="hover:text-primary-900 transition-colors">
|
|
||||||
Solution
|
|
||||||
</a>
|
|
||||||
<a href="/#modules" className="hover:text-primary-900 transition-colors">
|
|
||||||
Modules
|
|
||||||
</a>
|
|
||||||
<a href="/#use-cases" className="hover:text-primary-900 transition-colors">
|
<a href="/#use-cases" className="hover:text-primary-900 transition-colors">
|
||||||
Use Cases
|
Use Cases
|
||||||
</a>
|
</a>
|
||||||
{/* Pricing 메뉴 임시 비활성
|
|
||||||
<Link to="/pricing?from=header" className="hover:text-primary-900 transition-colors">
|
<Link to="/pricing?from=header" className="hover:text-primary-900 transition-colors">
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */}
|
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge'
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
export default defineConfig(() => {
|
|
||||||
// Docker on macOS/Windows에서 파일 변경 감지를 위해 polling 필요
|
// Docker on macOS/Windows에서 파일 변경 감지를 위해 polling 필요
|
||||||
// CHOKIDAR_USEPOLLING=true 환경변수가 있을 때만 켬 (로컬 dev에선 비활성)
|
// CHOKIDAR_USEPOLLING=true 환경변수가 있을 때만 켬 (로컬 dev에선 비활성)
|
||||||
const usePolling = process.env.CHOKIDAR_USEPOLLING === 'true'
|
const usePolling = process.env.CHOKIDAR_USEPOLLING === 'true'
|
||||||
|
|
||||||
// Docker 안에서 dev 서버가 돌면 호스트의 localhost는 host.docker.internal로 접근해야 함
|
|
||||||
// VITE_API_TARGET 환경변수로 오버라이드 가능
|
|
||||||
const apiTarget =
|
const apiTarget =
|
||||||
process.env.VITE_API_TARGET ??
|
env.VITE_API_TARGET ||
|
||||||
|
env.VITE_API_BASE_URL ||
|
||||||
(usePolling ? 'http://host.docker.internal:8001' : 'http://localhost:8001')
|
(usePolling ? 'http://host.docker.internal:8001' : 'http://localhost:8001')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue