메인페이지 원복시키기
parent
c07ef255d4
commit
5f7e0051cd
|
|
@ -31,7 +31,6 @@ import {
|
|||
pickPrimaryUrl,
|
||||
type ClassifiedUrls,
|
||||
} from '../lib/classifyUrls';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { PLATFORM_META } from '@/features/clinics/components/PlatformChips';
|
||||
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 value = urls[key];
|
||||
const status = validateField(value, key);
|
||||
const inactiveColor = isHero ? '#94a3b8' /* slate-400 */ : 'rgba(255,255,255,0.4)';
|
||||
return (
|
||||
<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 z-10">
|
||||
{/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */}
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2">
|
||||
<Icon
|
||||
size={16}
|
||||
style={{ color: value ? meta.color : inactiveColor }}
|
||||
style={{ color: value ? meta.color : isHero ? '#94a3b8' : 'rgba(255,255,255,0.4)' }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -181,8 +178,8 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
|||
spellCheck={false}
|
||||
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' && (
|
||||
<CheckFilled size={16} className="text-emerald-500" />
|
||||
)}
|
||||
|
|
@ -201,18 +198,18 @@ export default function MultiChannelInput({ variant = 'hero', onAnalyze }: Multi
|
|||
</p>
|
||||
|
||||
{/* 분석 시작 버튼 — DS Primary pill */}
|
||||
<Button
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
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
|
||||
? 'from-brand-purple to-brand-purple-deep text-white'
|
||||
: 'from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky text-primary-900'
|
||||
? 'from-[#4F1DA1] to-[#021341] text-white hover:from-[#AF90FF] hover:to-[#AF90FF]'
|
||||
: 'from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] text-primary-900 hover:scale-[1.02]'
|
||||
}`}
|
||||
>
|
||||
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" />
|
||||
</Button>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1" />
|
||||
</button>
|
||||
|
||||
{/* 보조 안내 (하단) */}
|
||||
<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 { AnalysisTab } from '../components/tabs/AnalysisTab';
|
||||
import { SettingsTab } from '../components/tabs/SettingsTab';
|
||||
import { mockWorkspace } from '../data/mockClinicWorkspace';
|
||||
import { useClinicWorkspace } from '../hooks/useClinicWorkspace';
|
||||
import { PageContainer } from '@/shared/ui/page-container';
|
||||
|
||||
const VALID_TABS = ['analysis', 'settings'] as const;
|
||||
|
|
@ -31,24 +31,16 @@ export default function ClinicWorkspacePage() {
|
|||
const requested = searchParams.get('tab');
|
||||
const activeTab: TabKey = isTabKey(requested) ? requested : 'analysis';
|
||||
|
||||
// TODO: 실 API 연동 (useGetClinicHistory + report 메타 보강)
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
...mockWorkspace,
|
||||
clinic: {
|
||||
...mockWorkspace.clinic,
|
||||
clinicId: clinicId ?? mockWorkspace.clinic.clinicId,
|
||||
},
|
||||
}),
|
||||
[clinicId],
|
||||
);
|
||||
const { data, isLoading, error } = useClinicWorkspace(clinicId);
|
||||
|
||||
const sortedRuns = useMemo(
|
||||
() =>
|
||||
[...data.runs].sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
),
|
||||
[data.runs],
|
||||
data
|
||||
? [...data.runs].sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
const latestRun = sortedRuns[0];
|
||||
const previousRun = sortedRuns[1];
|
||||
|
|
@ -79,6 +71,28 @@ export default function ClinicWorkspacePage() {
|
|||
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 (
|
||||
<div className="pt-20 flex-1 flex flex-col">
|
||||
<WorkspaceHeader
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default function CTA() {
|
|||
>
|
||||
<MultiChannelInput variant="cta" onAnalyze={handleAnalyze} />
|
||||
|
||||
{/* 보조 CTA — 가격 플랜 (임시 비활성)
|
||||
{/* 보조 CTA — 가격 플랜 */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Link
|
||||
to="/pricing?from=cta"
|
||||
|
|
@ -68,7 +68,6 @@ export default function CTA() {
|
|||
가격 플랜 보기 →
|
||||
</Link>
|
||||
</div>
|
||||
*/}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,25 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||
import { useCreateClinic } from '@/shared/api/generated/clinics/clinics';
|
||||
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 = {
|
||||
instagram?: string;
|
||||
youtube?: string;
|
||||
facebook?: string;
|
||||
naverBlog?: string;
|
||||
gangnamUnni?: string;
|
||||
instagram?: string | string[];
|
||||
youtube?: string | string[];
|
||||
facebook?: string | string[];
|
||||
naverBlog?: string | 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 =
|
||||
| 'resuming'
|
||||
| 'discovering'
|
||||
|
|
@ -47,22 +56,9 @@ function clearSession() {
|
|||
Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k));
|
||||
}
|
||||
|
||||
// 백엔드 AnalysisStatus enum: discovering | collecting | analyzing | completed | failed
|
||||
// UI Phase 스텝과 매핑
|
||||
function phaseFromStatus(s: string): Phase {
|
||||
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';
|
||||
}
|
||||
// 백엔드 AnalysisStatus 4단계를 UI 5단계로 매핑.
|
||||
// analyzing 한 단계는 길어서 generating → planning 으로 시간 기반 분할.
|
||||
const ANALYZING_SPLIT_MS = 30_000;
|
||||
|
||||
interface UseAnalysisPipelineResult {
|
||||
phase: Phase;
|
||||
|
|
@ -98,13 +94,26 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
const runPipeline = useCallback(
|
||||
async (
|
||||
startUrl?: string,
|
||||
resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase },
|
||||
resumeFrom?: { reportId: string; clinicId?: string; runId?: string },
|
||||
) => {
|
||||
try {
|
||||
let reportId = resumeFrom?.reportId || '';
|
||||
let clinicId = resumeFrom?.clinicId;
|
||||
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
|
||||
if (!resumeFrom?.runId) {
|
||||
if (!startUrl) throw new Error('No URL provided');
|
||||
|
|
@ -120,11 +129,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
data: {
|
||||
clinic_id: clinicId,
|
||||
channels: {
|
||||
youtube: manualChannels?.youtube ?? null,
|
||||
instagram: manualChannels?.instagram ?? null,
|
||||
facebook: manualChannels?.facebook ?? null,
|
||||
naver_blog: manualChannels?.naverBlog ?? null,
|
||||
gangnam_unni: manualChannels?.gangnamUnni ?? null,
|
||||
youtube: pickFirst(manualChannels?.youtube),
|
||||
instagram: pickFirst(manualChannels?.instagram),
|
||||
facebook: pickFirst(manualChannels?.facebook),
|
||||
naver_blog: pickFirst(manualChannels?.naverBlog),
|
||||
gangnam_unni: pickFirst(manualChannels?.gangnamUnni),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -146,40 +155,20 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
const statusRes = await getAnalysisStatus(runId);
|
||||
if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.');
|
||||
const statusData = statusRes.data;
|
||||
setPhase(phaseFromStatus(statusData.status));
|
||||
setPhase(mapPhase(statusData.status));
|
||||
if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) {
|
||||
console.warn('[pipeline] Partial failures:', statusData.channel_errors);
|
||||
}
|
||||
if (statusData.status === 'completed') break;
|
||||
if (statusData.status === 'failed') {
|
||||
if (statusData.status === AnalysisStatus.completed) break;
|
||||
if (statusData.status === AnalysisStatus.failed) {
|
||||
throw new Error('파이프라인 실패: failed');
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await sleep(1500);
|
||||
}
|
||||
|
||||
setPhase('complete');
|
||||
clearSession();
|
||||
|
||||
// 최종 리포트 조회 (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);
|
||||
navigate(`/report/${reportId}`, { replace: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||
setError(msg);
|
||||
|
|
@ -203,13 +192,12 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
reportId: session.reportId,
|
||||
clinicId: session.clinicId || undefined,
|
||||
runId: session.runId || undefined,
|
||||
phase,
|
||||
});
|
||||
} else if (url || session.url) {
|
||||
hasStarted.current = false;
|
||||
runPipeline(url || session.url || undefined);
|
||||
}
|
||||
}, [phase, url, runPipeline]);
|
||||
}, [url, runPipeline]);
|
||||
|
||||
const handleAbort = useCallback(() => {
|
||||
clearSession();
|
||||
|
|
@ -229,11 +217,11 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
.then((res) => {
|
||||
if (res.status !== 200) throw new Error('status fetch failed');
|
||||
const status = res.data;
|
||||
if (status.status === 'completed') {
|
||||
if (status.status === AnalysisStatus.completed) {
|
||||
navigate(`/report/${urlReportId}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (status.status === 'failed') {
|
||||
if (status.status === AnalysisStatus.failed) {
|
||||
setError('Data collection failed. Please retry.');
|
||||
setPhase('collecting');
|
||||
return;
|
||||
|
|
@ -248,7 +236,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
reportId: urlReportId,
|
||||
clinicId: session.clinicId || undefined,
|
||||
runId,
|
||||
phase: resumePhaseFromStatus(status.status),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -266,7 +253,7 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
.then((res) => {
|
||||
if (res.status !== 200) throw new Error('status fetch failed');
|
||||
const status = res.data;
|
||||
if (status.status === 'completed') {
|
||||
if (status.status === AnalysisStatus.completed) {
|
||||
clearSession();
|
||||
navigate(`/report/${session.reportId}`, { replace: true });
|
||||
return;
|
||||
|
|
@ -275,7 +262,6 @@ export function useAnalysisPipeline(): UseAnalysisPipelineResult {
|
|||
reportId: session.reportId!,
|
||||
clinicId: session.clinicId || undefined,
|
||||
runId,
|
||||
phase: resumePhaseFromStatus(status.status),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
|
|||
|
|
@ -8,16 +8,7 @@
|
|||
*/
|
||||
import ky, { type KyInstance } from 'ky'
|
||||
|
||||
// Vite 가 빌드 시 치환하는 환경변수 (개발 서버에서도 동일).
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||
|
||||
export const kyInstance: KyInstance = ky.create({
|
||||
timeout: 10_000,
|
||||
|
|
@ -27,7 +18,7 @@ export const kyInstance: KyInstance = ky.create({
|
|||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
const apiKey = getApiKey();
|
||||
const apiKey = import.meta.env.VITE_API_KEY;
|
||||
if (apiKey) request.headers.set('x-api-key', apiKey);
|
||||
},
|
||||
// TODO: 인증 토큰 주입
|
||||
|
|
@ -46,7 +37,10 @@ export const customFetcher = async <T>(
|
|||
url: string,
|
||||
init?: RequestInit,
|
||||
): 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
|
||||
try {
|
||||
data = await response.json()
|
||||
|
|
|
|||
|
|
@ -39,26 +39,12 @@ export default function Navbar() {
|
|||
<a href="/#home" className="hover:text-primary-900 transition-colors">
|
||||
Product
|
||||
</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">
|
||||
Use Cases
|
||||
</a>
|
||||
{/* Pricing 메뉴 임시 비활성
|
||||
<Link to="/pricing?from=header" className="hover:text-primary-900 transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */}
|
||||
|
|
|
|||
|
|
@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge'
|
|||
export function cn(...inputs: ClassValue[]) {
|
||||
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 tailwindcss from '@tailwindcss/vite'
|
||||
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 필요
|
||||
// CHOKIDAR_USEPOLLING=true 환경변수가 있을 때만 켬 (로컬 dev에선 비활성)
|
||||
const usePolling = process.env.CHOKIDAR_USEPOLLING === 'true'
|
||||
|
||||
// Docker 안에서 dev 서버가 돌면 호스트의 localhost는 host.docker.internal로 접근해야 함
|
||||
// VITE_API_TARGET 환경변수로 오버라이드 가능
|
||||
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')
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue