Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 49 additions & 17 deletions frontend/app/(dashboard)/analyses/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { useParams } from 'next/navigation';
import { useAnalysisPolling } from '@/src/features/analysis/hooks/useAnalysisPolling';
import { InsightReport } from '@/src/features/analysis/components/InsightReport';
import { AnalysisProgress } from '@/src/features/analysis/components/AnalysisProgress';
import { Card } from '@/src/shared/components/ui';
import type { Analysis, AnalysisResult } from '@/src/features/analysis/types';

function BackLink() {
return (
<Link href="/dashboard" className="text-sm text-brand-600 hover:underline">
← 대시보드로 돌아가기
<Link
href="/dashboard"
className="inline-flex items-center gap-1 self-start rounded-lg px-3 py-1.5 text-sm text-gray-600 transition hover:bg-gray-100 hover:text-brand-700"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4" aria-hidden="true">
<path fillRule="evenodd" d="M12.7 4.3a1 1 0 010 1.4L8.42 10l4.29 4.3a1 1 0 01-1.42 1.4l-5-5a1 1 0 010-1.4l5-5a1 1 0 011.42 0z" clipRule="evenodd" />
</svg>
대시보드로 돌아가기
</Link>
);
}
Expand All @@ -34,25 +39,52 @@ function ResultHeader({
0,
);
return (
<Card className="p-5">
<p className="font-semibold text-gray-900">
{result.products.length}개 상품 · 총 {totalReviews}개 리뷰 분석 완료
</p>
{completedAt && (
<p className="mt-1 text-sm text-gray-500">
{formatCompletedAt(completedAt)} 생성
</p>
)}
</Card>
<div className="rounded-2xl bg-gradient-to-br from-brand-700 to-brand-900 p-6 text-white shadow-md">
<p className="text-sm font-semibold text-brand-100">분석 완료</p>
<div className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3 sm:gap-6">
<div className="min-w-0">
<p className="text-2xl font-bold leading-none sm:text-3xl">
{result.products.length}
</p>
<p className="mt-2 text-xs font-medium text-brand-100">분석 상품</p>
</div>
<div className="min-w-0">
<p className="text-2xl font-bold leading-none sm:text-3xl">{totalReviews}</p>
<p className="mt-2 text-xs font-medium text-brand-100">총 리뷰 수</p>
</div>
<div className="min-w-0">
<p className="break-words text-sm font-semibold leading-snug sm:text-base">
{completedAt ? formatCompletedAt(completedAt) : '—'}
</p>
<p className="mt-2 text-xs font-medium text-brand-100">생성 시각</p>
</div>
</div>
</div>
);
}

function WarningIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
<path d="M10.3 3.86 1.82 18a1 1 0 0 0 .86 1.5h18.64a1 1 0 0 0 .86-1.5L13.7 3.86a1 1 0 0 0-1.72 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
);
}

function ErrorView({ message }: { message: string }) {
return (
<Card className="border-red-200 bg-red-50 p-6">
<p className="font-medium text-red-800">분석에 실패했습니다.</p>
<p className="mt-1 text-sm text-red-700">{message}</p>
</Card>
<div className="rounded-2xl border border-red-200 bg-red-50 p-6">
<div className="flex items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-red-100 text-red-600">
<WarningIcon />
</span>
<div>
<p className="font-semibold text-red-800">분석에 실패했습니다.</p>
<p className="mt-1 text-sm text-red-700">{message}</p>
</div>
</div>
</div>
);
}

Expand Down
43 changes: 32 additions & 11 deletions frontend/app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query';
import { createAnalysis, listAnalyses } from '@/src/features/analysis/api';
import { UrlInput } from '@/src/features/analysis/components/UrlInput';
import { AnalysisCard } from '@/src/features/analysis/components/AnalysisCard';
import { Card } from '@/src/shared/components/ui';
import { EmptyState } from '@/src/shared/components/EmptyState';

export default function DashboardPage() {
Expand Down Expand Up @@ -34,21 +33,43 @@ export default function DashboardPage() {
const analyses = data?.analyses ?? [];

return (
<div className="flex flex-col gap-8">
<Card className="p-6">
<h1 className="mb-4 text-xl font-bold text-gray-900">경쟁 상품 분석</h1>
<p className="mb-4 text-sm text-gray-500">
<div className="flex flex-col gap-8 sm:gap-10">
<div className="overflow-hidden rounded-2xl bg-gradient-to-br from-brand-700 to-brand-900 p-6 shadow-lg sm:p-8">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-white ring-1 ring-inset ring-white/20">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<h1 className="text-xl font-bold text-white sm:text-2xl">경쟁 상품 분석</h1>
</div>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-brand-100">
경쟁 상품의 쿠팡 URL을 입력하면 AI가 리뷰를 분석해 개선 포인트를 알려드립니다.
</p>
<UrlInput onSubmit={handleSubmit} loading={submitting} />
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</Card>
<div className="mt-5 rounded-xl bg-white p-5 shadow-sm sm:p-6">
<UrlInput onSubmit={handleSubmit} loading={submitting} />
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</div>
</div>

<section>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900">최근 분석</h2>
{!isLoading && analyses.length > 0 && (
<span className="text-sm text-gray-500">총 {analyses.length}건</span>
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-xs font-medium text-brand-700">
총 {analyses.length}건
</span>
)}
</div>
{isLoading ? (
Expand All @@ -59,7 +80,7 @@ export default function DashboardPage() {
description="위에서 경쟁 상품 URL을 입력해 첫 분석을 시작해 보세요."
/>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2">
{analyses.map((analysis) => (
<AnalysisCard key={analysis.id} analysis={analysis} />
))}
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ export default function DashboardLayout({

return (
<div className="min-h-screen bg-gray-50">
<header className="border-b border-gray-200 bg-white">
<header className="sticky top-0 z-10 border-b border-gray-200 bg-white/80 backdrop-blur">
<Container className="flex items-center justify-between py-3">
<Wordmark />
<Button variant="outline" size="sm" onClick={handleLogout}>
로그아웃
</Button>
</Container>
</header>
<Container className="py-6">{children}</Container>
<Container className="py-8">{children}</Container>
</div>
);
}
41 changes: 37 additions & 4 deletions frontend/src/features/analysis/components/AnalysisCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const STATUS_VARIANT: Record<AnalysisStatus, NonNullable<BadgeProps['variant']>>
failed: 'danger',
};

const STATUS_DOT: Record<AnalysisStatus, string> = {
pending: 'bg-amber-400',
crawling: 'bg-amber-400',
analyzing: 'bg-amber-400',
completed: 'bg-emerald-500',
failed: 'bg-red-500',
};

function summarizeUrls(urls: string[]): string {
if (urls.length === 0) return 'URL 없음';
const [first, ...rest] = urls;
Expand All @@ -34,16 +42,41 @@ function formatDate(iso: string): string {
}

export function AnalysisCard({ analysis }: AnalysisCardProps) {
const isCompleted = analysis.status === 'completed';
return (
<Link
href={`/analyses/${analysis.id}`}
className="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition hover:border-brand-400 hover:shadow"
className="group block min-w-0 rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-md"
>
<div className="flex items-center justify-between gap-2">
<Badge variant={STATUS_VARIANT[analysis.status]}>{STATUS_LABEL[analysis.status]}</Badge>
<span className="text-xs text-gray-500">{formatDate(analysis.created_at)}</span>
<Badge variant={STATUS_VARIANT[analysis.status]} className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_DOT[analysis.status]}`} aria-hidden="true" />
{STATUS_LABEL[analysis.status]}
</Badge>
<span className="shrink-0 whitespace-nowrap text-xs text-gray-400">{formatDate(analysis.created_at)}</span>
</div>
<div className="mt-3 flex min-w-0 items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 shrink-0 text-gray-400"
aria-hidden="true"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
<p className="truncate text-sm text-gray-800">{summarizeUrls(analysis.urls)}</p>
</div>
<p className="mt-2 truncate text-sm text-gray-800">{summarizeUrls(analysis.urls)}</p>
{isCompleted && (
<p className="mt-3 text-right text-xs font-medium text-brand-600">
결과 보기 <span className="transition group-hover:translate-x-0.5">→</span>
</p>
)}
</Link>
);
}
30 changes: 15 additions & 15 deletions frontend/src/features/analysis/components/AnalysisProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Card } from '@/src/shared/components/ui';
import type { AnalysisStatus } from '@/src/features/analysis/types';

const PROGRESS_LABEL: Record<AnalysisStatus, string> = {
Expand Down Expand Up @@ -64,20 +63,21 @@ function CheckIcon() {
function StepIndicator({ state, index }: { state: StepState; index: number }) {
if (state === 'done') {
return (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-600 text-white">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-600 text-white shadow-sm shadow-brand-600/30 transition-colors duration-300">
<CheckIcon />
</span>
);
}
if (state === 'current') {
return (
<span className="flex h-10 w-10 shrink-0 animate-pulse items-center justify-center rounded-full border-2 border-brand-600 text-sm font-semibold text-brand-600">
{index + 1}
<span className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-2 border-brand-600 bg-brand-50 text-sm font-semibold text-brand-700 transition-colors duration-300">
<span className="absolute inset-0 animate-ping rounded-full border-2 border-brand-400/60" aria-hidden="true" />
<span className="relative">{index + 1}</span>
</span>
);
}
return (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 border-gray-300 text-sm font-semibold text-gray-400">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-2 border-gray-200 bg-gray-50 text-sm font-semibold text-gray-400 transition-colors duration-300">
{index + 1}
</span>
);
Expand All @@ -87,8 +87,8 @@ export function AnalysisProgress({ status }: { status: AnalysisStatus }) {
const isActive = status !== 'completed' && status !== 'failed';

return (
<Card className="p-8">
<ol className="flex items-center">
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<ol className="flex items-start">
{STEPS.map((step, index) => {
const state = stepStateFor(index, status);
const isLast = index === STEPS.length - 1;
Expand All @@ -103,17 +103,17 @@ export function AnalysisProgress({ status }: { status: AnalysisStatus }) {
<span
className={
state === 'upcoming'
? 'text-xs font-medium text-gray-400'
: 'text-xs font-medium text-brand-700'
? 'text-xs font-medium text-gray-400 transition-colors duration-300'
: 'text-xs font-semibold text-brand-700 transition-colors duration-300'
}
>
{step.label}
</span>
</div>
{!isLast && (
<div
className={`mx-2 mb-6 h-0.5 flex-1 rounded-full ${
connectorDone ? 'bg-brand-600' : 'bg-gray-300'
className={`mx-2 mt-[22px] h-0.5 flex-1 rounded-full transition-colors duration-500 ${
connectorDone ? 'bg-brand-600' : 'bg-gray-200'
}`}
aria-hidden="true"
/>
Expand All @@ -124,16 +124,16 @@ export function AnalysisProgress({ status }: { status: AnalysisStatus }) {
</ol>

{isActive && (
<div className="mt-8 flex flex-col items-center gap-2 text-center">
<div className="mt-8 flex flex-col items-center gap-3 rounded-xl bg-brand-50/60 px-4 py-6 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-600"
className="h-8 w-8 animate-spin rounded-full border-4 border-brand-100 border-t-brand-600"
role="status"
aria-label="로딩 중"
/>
<p className="font-medium text-gray-900">{PROGRESS_LABEL[status]}</p>
<p className="font-semibold text-brand-800">{PROGRESS_LABEL[status]}</p>
<p className="text-sm text-gray-500">분석 중입니다. 잠시만 기다려 주세요.</p>
</div>
)}
</Card>
</div>
);
}
Loading
Loading