[refactor] negodata/front: 인터넷 최저가 상세 시트 재설계 — 실구매가 중심 + 근거 검증
협상 근거로 쓰는 화면이라 "얼마에 살 수 있고, 그게 어디서 나온 값인가"를
한 번에 검증할 수 있게 정보 구조를 다시 짰다.
구조: 결론 → 판단 → 근거
- 히어로: 합계(상품가+배송비) 32px + 단가 대비 판정을 한 블록에.
면은 브랜드보다 채도를 낮춘 --accent 표면, 값만 브랜드 컬러(화면에 단 하나).
- 판매처 비교표: 상품가/배송비/합계를 몰별로. 상품가 최저와 실구매가 최저가
다를 수 있어 둘 다 표시. 결과 없는 몰도 '미발견' 행으로 남겨 빈손임을 드러낸다.
- 검색어(찾은 상품명) + 복사 버튼 → 판매처에서 다시 찾아볼 때 붙여넣기용.
- 가격 추이: 상품가 단일 기준. 합계 기준은 배송비를 아는 회차에만 값이 생겨
(네이버는 배송비 미제공 — 실측 39/39) 기준을 바꾸면 값의 의미와 표본 수가
동시에 변한다. 기준선(상품 단가) 추가 — 이 선 위면 사는 게 손해.
- 수집 이력: 미발견도 노출(전체의 30%가 not_found 인데 그동안 숨겨져 있었다).
데이터 정직성
- 배송비 미상(전체 69%)을 0원으로 뭉개지 않는다 — 무료배송으로 읽히면 안 된다.
모르면 히어로 라벨이 '합계' → '상품가'로 바뀌고 그 사실을 문구로 밝힌다.
- 화면 주인공(합계)과 목표가 산정에 쓰이는 값(상품가)이 다르므로 각주로 명시.
- 금액 옆 배송 주석은 '+3,500'(연산으로 오해) → '배송비 포함(3,500원)'.
접근성·일관성
- 대비 전수 계산: --accent 면 위에서 muted-fg(4.16)·amber-700(4.28)이 AA 미달이라
accent-foreground(9.85)·800 계열(6.0~6.5)로 교체. 히어로는 큰 글씨 기준 통과.
- rose(파괴적 뉘앙스) 제거 → 브랜드 인디고 한 톤. 장식 아이콘 전부 제거.
- 타이포 7단계를 상수 T 로 고정(임의 크기 금지).
- 차트 점 3종 모양을 DOT 상수로 추출해 차트와 범례가 공유 — 범례는 차트 밖이라
--color-price 가 해석되지 않아 색이 어긋났다. 전역 토큰으로 통일.
- Y축 눈금 1·2·5×10ⁿ, 기준 변경 시 애니메이션 끔, 커스텀 X축 tick 제거
(recharts 의 가장자리 앵커 보정을 건너뛰어 점과 라벨이 어긋났다).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5e24741395
commit
89652fac1f
@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChartSpline, ExternalLink, Loader2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { useGetLowestPrice } from '@/api/generated/item/item';
|
||||
import type { LowestPriceEntry } from '@/api/generated/model';
|
||||
import type { Product } from '../types';
|
||||
@ -16,201 +17,436 @@ type LowestPriceHistorySheetProps = {
|
||||
};
|
||||
|
||||
// LowestPriceWebsite 코드(백엔드 common/enums.py) → 표시 라벨
|
||||
const WEBSITE_LABEL: Record<number, string> = {
|
||||
1: '네이버',
|
||||
2: '쿠팡',
|
||||
3: 'G마켓',
|
||||
4: '옥션',
|
||||
5: '11번가',
|
||||
99: '기타',
|
||||
};
|
||||
const WEBSITE_LABEL: Record<number, string> = { 1: '네이버', 2: '쿠팡', 3: 'G마켓', 4: '옥션', 5: '11번가', 99: '기타' };
|
||||
const websiteLabel = (code?: number) => WEBSITE_LABEL[code ?? 99] ?? '기타';
|
||||
|
||||
// 단일 시리즈(인터넷 최저가) — 가격 UI 컨벤션인 rose 를 라이트/다크 쌍으로(통계 팔레트와 동일 문법).
|
||||
// by_mall 의 source 키 → 라벨. 기본 두 몰은 결과가 없어도 행을 만들어 "빈손"임을 드러낸다.
|
||||
const BASE_SOURCES = ['naver', 'coupang'] as const;
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
naver: '네이버', coupang: '쿠팡', gmarket: 'G마켓', auction: '옥션', st11: '11번가',
|
||||
};
|
||||
|
||||
// 액센트는 브랜드 인디고(--primary) 하나로 통일한다. rose 는 파괴적 액션 뉘앙스라 쓰지 않는다.
|
||||
// 대비: #5e6ad2 on white 4.7:1 / #8b93e8 on dark card 6.06:1 (둘 다 AA).
|
||||
const chartConfig = {
|
||||
price: { label: '인터넷 최저가', theme: { light: '#f43f5e', dark: '#fb7185' } },
|
||||
// 브랜드 토큰을 그대로 넘긴다 — 범례는 차트 밖(=--color-price 스코프 밖)이라
|
||||
// 하드코딩 색을 쓰면 둘이 어긋난다. 같은 var 를 보게 해서 항상 일치시킨다.
|
||||
price: { label: '최저가', color: 'var(--primary)' },
|
||||
// 단가 기준선 — 흰 배경 5.03:1(AA). 다크는 amber-400.
|
||||
contract: { label: '단가 기준선', theme: { light: '#bb4d00', dark: '#fbbf24' } },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const won = (n: number) => `₩${Math.round(n).toLocaleString()}`;
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
// 꺾은선 점 3종의 모양 정의 — 차트(PricePoint)와 범례(DotLegend)가 이 한 곳을 공유한다.
|
||||
// 색은 전역 토큰(var(--primary)/var(--card))이라 차트 안팎 어디서든 동일하게 그려진다.
|
||||
const DOT = {
|
||||
normal: (cx: number, cy: number) => <circle cx={cx} cy={cy} r={4} fill="var(--primary)" />,
|
||||
peak: (cx: number, cy: number) => (
|
||||
<circle cx={cx} cy={cy} r={4.5} fill="var(--card)" stroke="var(--primary)" strokeWidth={2} />
|
||||
),
|
||||
latest: (cx: number, cy: number) => (
|
||||
<>
|
||||
<circle cx={cx} cy={cy} r={7} fill="var(--primary)" />
|
||||
<circle cx={cx} cy={cy} r={7} fill="none" stroke="var(--card)" strokeWidth={2.5} />
|
||||
</>
|
||||
),
|
||||
} as const;
|
||||
|
||||
/** 견적 상세 드로어와 같은 문법의 섹션 카드 — 11px 볼드 타이틀 + 하단 구분선. */
|
||||
function SectionCard({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
|
||||
// ── 타이포 스케일 — 이 화면에서 쓰는 크기는 이 7단계뿐이다(임의 크기 금지) ──
|
||||
const T = {
|
||||
hero: 'text-[32px] font-bold leading-none tracking-tight', // 합계 (화면당 1개)
|
||||
major: 'text-[18px]', // 통계값
|
||||
title: 'text-[15px] font-medium', // 카드 제목
|
||||
body: 'text-[13px]', // 본문 — 라벨·상품명·경고·이력 시각·금액
|
||||
meta: 'text-xs', // 보조 — 메타·서브라인 (12px)
|
||||
fine: 'text-[11px]', // 부가 — 배송비 주석·차트 눈금
|
||||
tick: 11,
|
||||
} as const;
|
||||
|
||||
const num = (n: number) => Math.round(n).toLocaleString();
|
||||
/** 이력용 짧은 타임스탬프 — 24시간제라 등폭 숫자와 맞물려 폭이 흔들리지 않는다. */
|
||||
const stampLabel = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${d.getMonth() + 1}.${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
const clockLabel = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
/** 금액에 붙는 배송비 주석. 금액은 '이미 배송비가 더해진 합계'이므로
|
||||
* `+3,500` 처럼 연산으로 읽히는 표기를 쓰지 않는다(더 더해야 하나? 로 오해된다). */
|
||||
const shipNote = (shipping: number | null) =>
|
||||
shipping == null ? '배송비 미상' : shipping === 0 ? '무료배송' : `배송비 포함(${num(shipping)}원)`;
|
||||
|
||||
/** Y축 눈금을 1·2·5 × 10ⁿ 단위로 떨어뜨린다 — 1원 단위 눈금이 나오지 않게. */
|
||||
function niceScale(min: number, max: number, target = 4) {
|
||||
let lo = min;
|
||||
let hi = max;
|
||||
if (hi <= lo) { lo = min * 0.95; hi = max * 1.05; } // 값이 하나뿐이거나 전부 같을 때
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(Math.max((hi - lo) / target, 1))));
|
||||
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= (hi - lo) / target) ?? 10 * mag;
|
||||
const from = Math.floor(lo / step) * step;
|
||||
const to = Math.ceil(hi / step) * step;
|
||||
const ticks: number[] = [];
|
||||
for (let v = from; v <= to + step / 2; v += step) ticks.push(Math.round(v));
|
||||
return { domain: [from, to] as [number, number], ticks };
|
||||
}
|
||||
|
||||
// 몰 1건의 가격 구성. shipping=null 은 "배송비를 모른다"(0원이 아니다).
|
||||
// 네이버 쇼핑 API 는 배송비를 주지 않아 항상 null 이다 — 실측 39/39.
|
||||
type Mall = { source: string; label: string; price: number; shipping: number | null; name?: string; url?: string };
|
||||
|
||||
const readMalls = (e: LowestPriceEntry): Mall[] => {
|
||||
const out: Mall[] = [];
|
||||
for (const raw of e.by_mall ?? []) {
|
||||
const source = String(raw.source ?? '').toLowerCase();
|
||||
const price = Number(raw.price);
|
||||
if (!source || !Number.isFinite(price)) continue;
|
||||
// shipping_type 이 있어야 배송비를 신뢰한다. 없으면 미상(null) — 0원으로 뭉개면 무료배송처럼 읽힌다.
|
||||
const shipping = raw.shipping_type != null ? Number(raw.shipping_fee ?? 0) : null;
|
||||
out.push({
|
||||
source,
|
||||
label: SOURCE_LABEL[source] ?? String(raw.mall_name ?? source),
|
||||
price,
|
||||
shipping: Number.isFinite(shipping as number) ? shipping : null,
|
||||
name: raw.name ? String(raw.name) : undefined,
|
||||
url: raw.detail_url ? String(raw.detail_url) : undefined,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.price - b.price);
|
||||
};
|
||||
|
||||
/** 수집 1건을 화면용으로 정리. total 은 배송비를 아는 경우에만 값이 생긴다. */
|
||||
function readEntry(e: LowestPriceEntry) {
|
||||
const ok = !!e.success_yn && e.lp_price != null;
|
||||
const malls = readMalls(e);
|
||||
const itemPrice = ok ? Number(e.lp_price) : null;
|
||||
const winner = malls[0]; // 최저가를 낸 몰(가격 오름차순 첫 행). 옛 이력엔 by_mall 이 없어 undefined
|
||||
const shipping = winner?.shipping ?? null;
|
||||
return {
|
||||
entry: e, ok, malls, winner, itemPrice, shipping,
|
||||
total: itemPrice != null && shipping != null ? itemPrice + shipping : null,
|
||||
};
|
||||
}
|
||||
/** 섹션 카드 — 제목 + 우측 메타 + 보조 설명 한 줄. */
|
||||
function SectionCard({ title, meta, sub, children }: { title: ReactNode; meta?: ReactNode; sub?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
|
||||
<div className="flex items-center justify-between border-b border-border pb-1">
|
||||
<span className="font-bold text-foreground text-[11px] font-sans">{title}</span>
|
||||
{action}
|
||||
<Card className="gap-0 rounded-xl border-border p-[18px] shadow-none">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={cn('min-w-0 truncate text-foreground', T.title)}>{title}</span>
|
||||
{meta}
|
||||
</div>
|
||||
{children}
|
||||
{sub && <div className={cn('mt-0.5 text-muted-foreground', T.meta)}>{sub}</div>}
|
||||
<div className="mt-3.5">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력.
|
||||
// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
|
||||
// 인터넷 최저가 상세 — 판매처별 실구매가·가격 추이·수집 이력.
|
||||
// 협상 근거로 쓰는 값이므로 "어디서 얼마에 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
|
||||
export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) {
|
||||
// GET 이 서버에서 lps_db 증분 동기화를 겸하므로, 열 때마다 최신 상태가 온다.
|
||||
const { data, isLoading } = useGetLowestPrice(product.item_id);
|
||||
const labels = useLabels(); // 회사가 '상품 단가'를 '계약 단가' 등으로 바꿔 쓸 수 있다
|
||||
const priceLabel = labels('item.price');
|
||||
|
||||
const entries = data?.results ?? [];
|
||||
const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null);
|
||||
const rows = (data?.results ?? []).map(readEntry);
|
||||
const successes = rows.filter((r) => r.ok);
|
||||
const latest = successes[0]; // API 가 최신순으로 준다
|
||||
const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null;
|
||||
const diff = product.price != null && representative != null ? Number(representative) - product.price : null;
|
||||
const missCount = rows.length - successes.length;
|
||||
|
||||
// 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다.
|
||||
// 헤드라인 — 합계가 주인공. 배송비를 모르면 상품가로 라벨을 바꿔 '배송비 포함'을 참칭하지 않는다.
|
||||
const heroValue = latest ? (latest.total ?? latest.itemPrice) : null;
|
||||
const heroIsTotal = latest?.total != null;
|
||||
const diff = product.price != null && heroValue != null ? heroValue - product.price : null;
|
||||
|
||||
// 검색어(= 최저가의 근거가 된 상품명) — 판매처에서 다시 찾아볼 때 그대로 붙여넣을 수 있게 복사를 붙인다.
|
||||
const queryText = latest?.entry.lp_name || latest?.winner?.name || '';
|
||||
const copyQuery = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(queryText);
|
||||
showToast('검색어를 복사했습니다.', 'success');
|
||||
} catch {
|
||||
showToast('복사할 수 없습니다. 텍스트를 직접 선택해 주세요.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 추이는 '상품가' 단일 기준으로만 그린다.
|
||||
// 합계(=상품가+배송비)는 배송비를 아는 회차에만 값이 생겨(네이버는 항상 미제공)
|
||||
// 기준을 바꾸면 '값의 의미'와 '표본 수'가 동시에 변한다 — 선 모양의 변화가
|
||||
// 가격 변동인지 표본 변화인지 구분이 안 된다. 상품가는 모든 성공 수집에 존재해
|
||||
// 추이가 왜곡되지 않고, 기준선(상품 단가)·목표가 산정과도 단위가 같다.
|
||||
// 실구매가(합계)는 '지금 얼마에 살 수 있나'라서 요약 카드가 맡는다.
|
||||
const chartData = [...successes]
|
||||
.reverse()
|
||||
.map((e) => ({ t: e.crawl_end_time ?? '', price: e.lp_price }));
|
||||
.map((r) => ({ t: r.entry.crawl_end_time ?? '', price: r.itemPrice! }));
|
||||
const values = chartData.map((d) => d.price);
|
||||
const stats = values.length
|
||||
? { min: Math.min(...values), max: Math.max(...values), avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length) }
|
||||
: null;
|
||||
const maxAt = stats ? values.lastIndexOf(stats.max) : -1;
|
||||
const scale = stats
|
||||
? niceScale(Math.min(stats.min, product.price ?? stats.min), Math.max(stats.max, product.price ?? stats.max))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Sheet open title="인터넷 최저가 상세" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
{/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */}
|
||||
<div className="rounded border border-border/80 bg-muted/25 p-3">
|
||||
<Typography as="p" variant="caption" className="font-medium">{product.name}</Typography>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<span className="font-mono text-[26px] leading-none font-bold text-rose-600 dark:text-rose-400">
|
||||
{representative != null ? won(Number(representative)) : '수집 전'}
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
|
||||
{/* ── 요약: 판매처별 내역 → 합계 → 단가 대비 → 출처를 한 섹션으로 ── */}
|
||||
<SectionCard title={product.name}>
|
||||
{latest ? (
|
||||
<>
|
||||
{/* 결론 먼저 — 카드를 열면 여기에 시선이 먼저 닿아야 한다.
|
||||
면은 브랜드보다 채도를 낮춘 인디고 계열 표면 토큰(--accent, #ececf4).
|
||||
이 면 위에서는 muted-foreground(4.16:1)·amber-700(4.28:1)이 AA 미달이라
|
||||
라벨은 accent-foreground(9.85:1), 판정은 800 계열(6.0~6.5:1)로 한 단계 진하게 쓴다. */}
|
||||
<div className="rounded-xl bg-accent px-4 py-4">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={cn('text-accent-foreground', T.meta)}>
|
||||
{heroIsTotal ? '합계 (배송비 포함)' : '상품가 (배송비 미포함)'}
|
||||
</span>
|
||||
{diff != null && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
diff <= 0
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{diff <= 0 ? <TrendingDown size={12} /> : <TrendingUp size={12} />}
|
||||
단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))}
|
||||
{latest.entry.crawl_end_time && (
|
||||
<span className={cn('shrink-0 text-accent-foreground', T.meta)}>
|
||||
{clockLabel(latest.entry.crawl_end_time)} 수집
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="p" variant="caption" className="mt-1.5">
|
||||
상품 단가 {product.price != null ? won(product.price) : '-'}
|
||||
{latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}</>}
|
||||
</Typography>
|
||||
<p className="mt-1.5">
|
||||
<span className={cn('tabular-nums text-primary', T.hero)}>{num(heroValue!)}</span>
|
||||
<span className={cn('ml-1 text-accent-foreground', T.body)}>원</span>
|
||||
</p>
|
||||
{diff != null && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 leading-snug',
|
||||
T.body,
|
||||
diff > 0 ? 'text-amber-800 dark:text-amber-400' : 'text-emerald-800 dark:text-emerald-300',
|
||||
)}
|
||||
>
|
||||
{priceLabel} {num(product.price!)}원보다{' '}
|
||||
<span className="font-medium tabular-nums">
|
||||
{num(Math.abs(diff))}원 {diff > 0 ? '높습니다' : '낮습니다'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */}
|
||||
{latest && (
|
||||
<SectionCard
|
||||
title="최저가 출처"
|
||||
action={<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>}
|
||||
>
|
||||
{latest.lp_name ? (
|
||||
<Typography as="p" variant="small" className="text-[12.5px] leading-snug">{latest.lp_name}</Typography>
|
||||
) : (
|
||||
<Typography as="p" variant="caption">출처 상세 미수집(이전 버전 수집분)</Typography>
|
||||
<div className="mt-3">
|
||||
<MallTable malls={latest.malls} />
|
||||
</div>
|
||||
|
||||
{/* 검색어 — 이 가격의 근거가 된 상품이 무엇인지 밝힌다 */}
|
||||
<div className="mt-3 rounded-xl bg-muted/40 px-4 py-3">
|
||||
<p className={cn('text-muted-foreground', T.meta)}>검색어</p>
|
||||
<div className="mt-1 flex items-start justify-between gap-2">
|
||||
<p className={cn('leading-snug', queryText ? 'text-foreground' : 'text-muted-foreground', T.body)}>
|
||||
{queryText || '출처 상세 미수집(이전 버전 수집분)'}
|
||||
</p>
|
||||
{queryText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyQuery()}
|
||||
className={cn(
|
||||
'shrink-0 cursor-pointer rounded border border-border bg-card px-2 py-1 text-foreground transition-colors hover:bg-muted',
|
||||
T.meta,
|
||||
)}
|
||||
{latest.lp_url && (
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출처 — 판매 페이지(수집 시각은 히어로 우측으로 올렸다) */}
|
||||
<div className="mt-3.5">
|
||||
{(latest.entry.lp_url || latest.winner?.url) && (
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={latest.lp_url}
|
||||
href={latest.entry.lp_url || latest.winner?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-[11px] font-medium text-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
className={cn('rounded border border-border px-2.5 py-1 text-foreground transition-colors hover:bg-muted', T.meta)}
|
||||
>
|
||||
판매 페이지에서 확인 <ExternalLink size={11} />
|
||||
판매 페이지 링크 이동
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* 화면의 주인공(합계)과 시스템이 쓰는 값(상품가)이 다르므로 숨기지 않고 밝힌다 */}
|
||||
{heroIsTotal && latest.shipping! > 0 && (
|
||||
<p className={cn('mt-2 leading-relaxed text-muted-foreground', T.meta)}>
|
||||
목표가 산정에는 배송비를 뺀 상품가 {num(latest.itemPrice!)}원이 사용됩니다.
|
||||
</p>
|
||||
)}
|
||||
{!heroIsTotal && (
|
||||
<p className={cn('mt-2 leading-relaxed text-muted-foreground', T.meta)}>
|
||||
판매처가 배송비를 제공하지 않아 상품가만 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className={cn('text-muted-foreground', T.body)}>
|
||||
아직 수집된 최저가가 없습니다. {priceLabel} {product.price != null ? `${num(product.price)}원` : '-'}
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 가격 추이 ── */}
|
||||
<SectionCard
|
||||
title="가격 추이"
|
||||
action={
|
||||
successes.length > 0 ? (
|
||||
<Typography variant="caption">{successes.length}회 수집</Typography>
|
||||
) : undefined
|
||||
meta={<span className={cn('shrink-0 text-muted-foreground', T.meta)}>상품가 기준</span>}
|
||||
sub={
|
||||
<>
|
||||
배송비 제외 · 성공 {successes.length}회
|
||||
{missCount > 0 && ` · 미발견 ${missCount}건 제외`}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{chartData.length >= 2 ? (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 12, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={timeLabel} fontSize={10} />
|
||||
{chartData.length >= 2 && scale ? (
|
||||
<>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-56 w-full">
|
||||
<LineChart data={chartData} margin={{ top: 18, right: 12, left: 4, bottom: 8 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
{/* 커스텀 tick 은 recharts 의 눈금 정렬 보정(가장자리 앵커 조정)을 건너뛰어
|
||||
점과 라벨이 어긋난다 — 기본 렌더러에 포맷터만 준다. */}
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={16}
|
||||
tickFormatter={stampLabel}
|
||||
fontSize={T.tick}
|
||||
height={30}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(v) => Number(v).toLocaleString()}
|
||||
fontSize={10}
|
||||
domain={scale.domain}
|
||||
ticks={scale.ticks}
|
||||
tickFormatter={(v) => num(Number(v))}
|
||||
fontSize={T.tick}
|
||||
/>
|
||||
{/* 계약/상품 단가 기준선 — 최저가가 이 선 위에 있으면 사는 게 손해다 */}
|
||||
{product.price != null && (
|
||||
<ReferenceLine
|
||||
y={product.price}
|
||||
stroke="var(--color-contract)"
|
||||
strokeDasharray="5 4"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: `${priceLabel} ${num(product.price)}`,
|
||||
position: 'insideTopRight',
|
||||
fontSize: T.tick,
|
||||
fill: 'var(--color-contract)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChartTooltip cursor={{ strokeDasharray: '3 3' }} content={<PriceTooltip />} />
|
||||
{/* 기준 토글로 데이터가 통째로 바뀌므로 재생 애니메이션은 끈다(점이 튀어 보인다) */}
|
||||
<Line
|
||||
dataKey="price"
|
||||
type="monotone"
|
||||
type="linear"
|
||||
isAnimationActive={false}
|
||||
stroke="var(--color-price)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: 'var(--color-price)', strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
dot={(props) => <PricePoint {...props} lastIndex={chartData.length - 1} maxIndex={maxAt} />}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
{stats && (
|
||||
<div className="mt-3.5 grid grid-cols-3 gap-2">
|
||||
{([['최저', stats.min], ['평균', stats.avg], ['최고', stats.max]] as const).map(([label, v]) => (
|
||||
<div key={label} className="rounded-xl bg-muted/40 px-3 py-2.5">
|
||||
<div className={cn('text-muted-foreground', T.meta)}>{label}</div>
|
||||
<div className={cn('mt-0.5 tabular-nums text-foreground', T.major)}>{num(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 점 모양에 뜻이 있으므로 범례로 밝힌다 */}
|
||||
<DotLegend showPeak={stats != null && stats.max !== stats.min} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 py-3 text-muted-foreground">
|
||||
<ChartSpline size={14} className="shrink-0" />
|
||||
<Typography variant="caption">
|
||||
{successes.length === 1
|
||||
<p className={cn('py-3 text-muted-foreground', T.body)}>
|
||||
{chartData.length === 1
|
||||
? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.'
|
||||
: '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 수집 이력 ── */}
|
||||
<SectionCard title="최근 수집 이력">
|
||||
{/* ── 수집 이력 (최근 10건 고정 — 서버가 limit=10 으로 내려준다) ── */}
|
||||
<SectionCard
|
||||
title="수집 이력"
|
||||
meta={rows.length > 0 ? <span className={cn('shrink-0 text-muted-foreground', T.meta)}>최근 {rows.length}건</span> : undefined}
|
||||
sub={rows.length > 0 ? `성공 ${successes.length}건 · 미발견 ${missCount}건` : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<Typography variant="caption">이력을 불러오는 중...</Typography>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="space-y-1 rounded border border-dashed border-border bg-muted/10 py-6 text-center">
|
||||
<TrendingDown size={18} className="mx-auto text-muted-foreground/60" />
|
||||
<Typography as="p" variant="caption">
|
||||
<p className={cn('py-6 text-center text-muted-foreground', T.body)}>이력을 불러오는 중...</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className={cn('rounded-xl bg-muted/40 px-4 py-6 text-center text-muted-foreground', T.body)}>
|
||||
수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
|
||||
</Typography>
|
||||
</div>
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{entries.map((e, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-2 py-2 first:pt-0.5 last:pb-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="w-14 shrink-0 justify-center text-[10px]">
|
||||
{websiteLabel(e.website)}
|
||||
</Badge>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
|
||||
<ul>
|
||||
{rows.map((r, i) => {
|
||||
// 이력은 언제나 '실제 지불액' 기준 — 위 토글에 영향받지 않는다.
|
||||
const paid = r.total ?? r.itemPrice;
|
||||
const url = r.entry.lp_url || r.winner?.url;
|
||||
return (
|
||||
// 열 폭을 고정해 금액이 배송 주석 길이에 밀리지 않게 한다(모든 행의 금액 우측이 한 줄).
|
||||
<li
|
||||
key={i}
|
||||
className="grid min-h-[3.25rem] grid-cols-[auto_1fr_auto_auto] items-center gap-x-2 border-t border-border py-2.5 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<span className={cn('tabular-nums text-foreground', T.body)}>
|
||||
{r.entry.crawl_end_time ? stampLabel(r.entry.crawl_end_time) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5 font-mono text-[12px]">
|
||||
{e.success_yn && e.lp_price != null ? (
|
||||
<span className="font-semibold text-rose-600 dark:text-rose-400">{won(e.lp_price)}</span>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">미발견</span>
|
||||
)}
|
||||
{e.lp_url ? (
|
||||
{r.ok ? (
|
||||
<>
|
||||
<span className="flex justify-center">
|
||||
<Badge variant="secondary" className={cn('rounded-md', T.fine)}>
|
||||
{r.winner?.label ?? websiteLabel(r.entry.website)}
|
||||
</Badge>
|
||||
</span>
|
||||
{/* 금액과 배송비 주석을 세로로 쌓는다 — 가로로 붙이면 주석이 길어질수록
|
||||
금액 시작점이 밀리고, 열을 넓히면 다른 열을 잡아먹는다. */}
|
||||
<span className="text-right">
|
||||
<span className={cn('block font-medium tabular-nums text-primary', T.body)}>{num(paid!)}</span>
|
||||
<span className={cn('mt-0.5 block whitespace-nowrap text-muted-foreground', T.fine)}>
|
||||
{shipNote(r.shipping)}
|
||||
</span>
|
||||
</span>
|
||||
{url ? (
|
||||
<a
|
||||
href={e.lp_url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-rose-600 dark:hover:text-rose-400"
|
||||
title="판매 페이지 열기"
|
||||
className={cn('rounded border border-border px-2 py-1 text-center text-foreground transition-colors hover:bg-muted', T.meta)}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
링크 이동
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-3" /> /* 링크 없는 행도 가격 우측 정렬 유지 */
|
||||
<span />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// 값이 없는 행은 열을 쪼개 봐야 빈칸만 남는다 — 시각을 뺀 나머지 폭에 사유를 가운데 둔다.
|
||||
<span className={cn('col-span-3 text-center text-muted-foreground', T.fine)}>
|
||||
조건에 맞는 상품 없음
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
@ -219,14 +455,97 @@ export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistory
|
||||
);
|
||||
}
|
||||
|
||||
// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만)
|
||||
/** 점 모양 범례 — 채움/속 빔/크기가 각각 뜻이 있으므로 밝힌다.
|
||||
* 스와치는 DOT 정의를 그대로 써서 차트와 모양·색이 어긋날 수 없다. */
|
||||
function DotLegend({ showPeak }: { showPeak: boolean }) {
|
||||
const items = [
|
||||
{ key: 'normal', label: '수집 시점', draw: DOT.normal },
|
||||
...(showPeak ? [{ key: 'peak', label: '기간 내 최고가', draw: DOT.peak }] : []),
|
||||
{ key: 'latest', label: '최근 수집(현재값)', draw: DOT.latest },
|
||||
];
|
||||
return (
|
||||
<div className="mt-3.5 flex justify-end">
|
||||
<ul className={cn('flex flex-wrap items-center gap-x-3.5 gap-y-1 rounded-lg border border-border px-3 py-2 text-muted-foreground', T.fine)}>
|
||||
{items.map(({ key, label, draw }) => (
|
||||
<li key={key} className="inline-flex items-center gap-1.5">
|
||||
<svg width="18" height="18" aria-hidden className="shrink-0">
|
||||
{draw(9, 9)}
|
||||
</svg>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 꺾은선 점 — 마지막 수집은 크게(현재값), 최고가는 속 빈 원으로 구분한다. */
|
||||
function PricePoint(props: { cx?: number; cy?: number; index?: number; lastIndex: number; maxIndex: number }) {
|
||||
const { cx, cy, index = 0, lastIndex, maxIndex } = props;
|
||||
if (cx == null || cy == null) return <g />;
|
||||
if (index === lastIndex) return <g>{DOT.latest(cx, cy)}</g>;
|
||||
if (index === maxIndex) return <g>{DOT.peak(cx, cy)}</g>;
|
||||
return <g>{DOT.normal(cx, cy)}</g>;
|
||||
}
|
||||
|
||||
/** 판매처별 내역 — 상품가가 싼 곳과 배송비까지 더해 실제로 싼 곳이 다를 수 있어 둘 다 표시한다. */
|
||||
function MallTable({ malls }: { malls: Mall[] }) {
|
||||
if (malls.length === 0) return null;
|
||||
const keys = [...malls.map((m) => m.source), ...BASE_SOURCES.filter((s) => !malls.some((m) => m.source === s))];
|
||||
const bestItem = Math.min(...malls.map((m) => m.price));
|
||||
const totals = malls.filter((m) => m.shipping != null).map((m) => m.price + m.shipping!);
|
||||
const bestTotal = totals.length > 0 ? Math.min(...totals) : null;
|
||||
const unknown = malls.some((m) => m.shipping == null);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-muted/40 px-4 py-2.5">
|
||||
<div className={cn('grid grid-cols-[4rem_1fr_1fr_1fr] gap-x-2 pb-2 text-muted-foreground', T.meta)}>
|
||||
<span>판매처</span>
|
||||
<span className="text-right">상품가</span>
|
||||
<span className="text-right">배송비</span>
|
||||
<span className="text-right">합계</span>
|
||||
</div>
|
||||
{keys.map((k) => {
|
||||
const m = malls.find((x) => x.source === k);
|
||||
const total = m && m.shipping != null ? m.price + m.shipping : null;
|
||||
return (
|
||||
<div key={k} className={cn('grid grid-cols-[4rem_1fr_1fr_1fr] items-center gap-x-2 border-t border-border py-2', T.body)}>
|
||||
<span className="truncate text-foreground">{SOURCE_LABEL[k] ?? k}</span>
|
||||
{m ? (
|
||||
<>
|
||||
<span className={cn('text-right tabular-nums', m.price === bestItem ? 'font-medium text-foreground' : 'text-muted-foreground')}>
|
||||
{num(m.price)}
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-muted-foreground">
|
||||
{m.shipping == null ? '미상' : m.shipping === 0 ? '무료' : num(m.shipping)}
|
||||
</span>
|
||||
<span className={cn('text-right tabular-nums', total != null && total === bestTotal ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
|
||||
{total != null ? num(total) : '—'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="col-span-3 text-right text-muted-foreground">미발견</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{unknown && (
|
||||
<p className={cn('border-t border-border pt-2 leading-relaxed text-muted-foreground', T.fine)}>
|
||||
배송비를 제공하지 않는 판매처가 있어 합계 비교가 완전하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 툴팁 — 시각 + 금액
|
||||
function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">{timeLabel(p.t)}</Typography>
|
||||
<Typography as="p" variant="caption" className="font-mono text-foreground">{won(p.price)}</Typography>
|
||||
<div className="rounded-lg border border-border bg-background px-2.5 py-1.5 shadow-xl">
|
||||
<p className={cn('font-medium text-foreground', T.meta)}>{stampLabel(p.t)}</p>
|
||||
<p className={cn('tabular-nums text-muted-foreground', T.meta)}>{num(p.price)}원</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user