From 89652fac1f1deb16e44556ac912ae082c4000697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 16:06:27 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20negodata/front:=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EB=84=B7=20=EC=B5=9C=EC=A0=80=EA=B0=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=8B=9C=ED=8A=B8=20=EC=9E=AC=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=A4=EA=B5=AC=EB=A7=A4=EA=B0=80=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=20+=20=EA=B7=BC=EA=B1=B0=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 협상 근거로 쓰는 화면이라 "얼마에 살 수 있고, 그게 어디서 나온 값인가"를 한 번에 검증할 수 있게 정보 구조를 다시 짰다. 구조: 결론 → 판단 → 근거 - 히어로: 합계(상품가+배송비) 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) --- .../components/LowestPriceHistorySheet.tsx | 645 +++++++++++++----- 1 file changed, 482 insertions(+), 163 deletions(-) diff --git a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx index 7d21e10..8d1b671 100644 --- a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx +++ b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx @@ -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 = { - 1: '네이버', - 2: '쿠팡', - 3: 'G마켓', - 4: '옥션', - 5: '11번가', - 99: '기타', -}; +const WEBSITE_LABEL: Record = { 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 = { + 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) => , + peak: (cx: number, cy: number) => ( + + ), + latest: (cx: number, cy: number) => ( + <> + + + + ), +} 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 ( - -
- {title} - {action} + +
+ {title} + {meta}
- {children} + {sub &&
{sub}
} +
{children}
); } -// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력. -// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. +// 인터넷 최저가 상세 — 판매처별 실구매가·가격 추이·수집 이력. +// 협상 근거로 쓰는 값이므로 "어디서 얼마에 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. 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 ( -
- {/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */} -
- {product.name} -
- - {representative != null ? won(Number(representative)) : '수집 전'} - - {diff != null && ( - - {diff <= 0 ? : } - 단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))} - - )} -
- - 상품 단가 {product.price != null ? won(product.price) : '-'} - {latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}} - -
+
- {/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */} - {latest && ( - {websiteLabel(latest.website)}} - > - {latest.lp_name ? ( - {latest.lp_name} - ) : ( - 출처 상세 미수집(이전 버전 수집분) - )} - {latest.lp_url && ( - - 판매 페이지에서 확인 - - )} - - )} + {/* ── 요약: 판매처별 내역 → 합계 → 단가 대비 → 출처를 한 섹션으로 ── */} + + {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)로 한 단계 진하게 쓴다. */} +
+
+ + {heroIsTotal ? '합계 (배송비 포함)' : '상품가 (배송비 미포함)'} + + {latest.entry.crawl_end_time && ( + + {clockLabel(latest.entry.crawl_end_time)} 수집 + + )} +
+

+ {num(heroValue!)} + 원 +

+ {diff != null && ( +

0 ? 'text-amber-800 dark:text-amber-400' : 'text-emerald-800 dark:text-emerald-300', + )} + > + {priceLabel} {num(product.price!)}원보다{' '} + + {num(Math.abs(diff))}원 {diff > 0 ? '높습니다' : '낮습니다'} + +

+ )} +
+ +
+ +
+ + {/* 검색어 — 이 가격의 근거가 된 상품이 무엇인지 밝힌다 */} +
+

검색어

+
+

+ {queryText || '출처 상세 미수집(이전 버전 수집분)'} +

+ {queryText && ( + + )} +
+
+ + {/* 출처 — 판매 페이지(수집 시각은 히어로 우측으로 올렸다) */} +
+ {(latest.entry.lp_url || latest.winner?.url) && ( + + )} + {/* 화면의 주인공(합계)과 시스템이 쓰는 값(상품가)이 다르므로 숨기지 않고 밝힌다 */} + {heroIsTotal && latest.shipping! > 0 && ( +

+ 목표가 산정에는 배송비를 뺀 상품가 {num(latest.itemPrice!)}원이 사용됩니다. +

+ )} + {!heroIsTotal && ( +

+ 판매처가 배송비를 제공하지 않아 상품가만 표시합니다. +

+ )} +
+ + ) : ( +

+ 아직 수집된 최저가가 없습니다. {priceLabel} {product.price != null ? `${num(product.price)}원` : '-'} +

+ )} +
{/* ── 가격 추이 ── */} 0 ? ( - {successes.length}회 수집 - ) : undefined + meta={상품가 기준} + sub={ + <> + 배송비 제외 · 성공 {successes.length}회 + {missCount > 0 && ` · 미발견 ${missCount}건 제외`} + } > - {chartData.length >= 2 ? ( - - - - - Number(v).toLocaleString()} - fontSize={10} - /> - } /> - - - + {chartData.length >= 2 && scale ? ( + <> + + + + {/* 커스텀 tick 은 recharts 의 눈금 정렬 보정(가장자리 앵커 조정)을 건너뛰어 + 점과 라벨이 어긋난다 — 기본 렌더러에 포맷터만 준다. */} + + num(Number(v))} + fontSize={T.tick} + /> + {/* 계약/상품 단가 기준선 — 최저가가 이 선 위에 있으면 사는 게 손해다 */} + {product.price != null && ( + + )} + } /> + {/* 기준 토글로 데이터가 통째로 바뀌므로 재생 애니메이션은 끈다(점이 튀어 보인다) */} + } + activeDot={{ r: 6 }} + /> + + + + {stats && ( +
+ {([['최저', stats.min], ['평균', stats.avg], ['최고', stats.max]] as const).map(([label, v]) => ( +
+
{label}
+
{num(v)}
+
+ ))} +
+ )} + + {/* 점 모양에 뜻이 있으므로 범례로 밝힌다 */} + + ) : ( -
- - - {successes.length === 1 - ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' - : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} - -
+

+ {chartData.length === 1 + ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' + : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} +

)}
- {/* ── 수집 이력 ── */} - + {/* ── 수집 이력 (최근 10건 고정 — 서버가 limit=10 으로 내려준다) ── */} + 0 ? 최근 {rows.length}건 : undefined} + sub={rows.length > 0 ? `성공 ${successes.length}건 · 미발견 ${missCount}건` : undefined} + > {isLoading ? ( -
- - 이력을 불러오는 중... -
- ) : entries.length === 0 ? ( -
- - - 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. - -
+

이력을 불러오는 중...

+ ) : rows.length === 0 ? ( +

+ 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. +

) : ( -
    - {entries.map((e, i) => ( -
  • -
    - - {websiteLabel(e.website)} - - - {e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'} +
      + {rows.map((r, i) => { + // 이력은 언제나 '실제 지불액' 기준 — 위 토글에 영향받지 않는다. + const paid = r.total ?? r.itemPrice; + const url = r.entry.lp_url || r.winner?.url; + return ( + // 열 폭을 고정해 금액이 배송 주석 길이에 밀리지 않게 한다(모든 행의 금액 우측이 한 줄). +
    • + + {r.entry.crawl_end_time ? stampLabel(r.entry.crawl_end_time) : '-'} -
    -
    - {e.success_yn && e.lp_price != null ? ( - {won(e.lp_price)} + {r.ok ? ( + <> + + + {r.winner?.label ?? websiteLabel(r.entry.website)} + + + {/* 금액과 배송비 주석을 세로로 쌓는다 — 가로로 붙이면 주석이 길어질수록 + 금액 시작점이 밀리고, 열을 넓히면 다른 열을 잡아먹는다. */} + + {num(paid!)} + + {shipNote(r.shipping)} + + + {url ? ( + + 링크 이동 + + ) : ( + + )} + ) : ( - 미발견 + // 값이 없는 행은 열을 쪼개 봐야 빈칸만 남는다 — 시각을 뺀 나머지 폭에 사유를 가운데 둔다. + + 조건에 맞는 상품 없음 + )} - {e.lp_url ? ( - - - - ) : ( - /* 링크 없는 행도 가격 우측 정렬 유지 */ - )} -
    -
  • - ))} + + ); + })}
)}
@@ -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 ( +
+
    + {items.map(({ key, label, draw }) => ( +
  • + + {draw(9, 9)} + + {label} +
  • + ))} +
+
+ ); +} + +/** 꺾은선 점 — 마지막 수집은 크게(현재값), 최고가는 속 빈 원으로 구분한다. */ +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 ; + if (index === lastIndex) return {DOT.latest(cx, cy)}; + if (index === maxIndex) return {DOT.peak(cx, cy)}; + return {DOT.normal(cx, cy)}; +} + +/** 판매처별 내역 — 상품가가 싼 곳과 배송비까지 더해 실제로 싼 곳이 다를 수 있어 둘 다 표시한다. */ +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 ( +
+
+ 판매처 + 상품가 + 배송비 + 합계 +
+ {keys.map((k) => { + const m = malls.find((x) => x.source === k); + const total = m && m.shipping != null ? m.price + m.shipping : null; + return ( +
+ {SOURCE_LABEL[k] ?? k} + {m ? ( + <> + + {num(m.price)} + + + {m.shipping == null ? '미상' : m.shipping === 0 ? '무료' : num(m.shipping)} + + + {total != null ? num(total) : '—'} + + + ) : ( + 미발견 + )} +
+ ); + })} + {unknown && ( +

+ 배송비를 제공하지 않는 판매처가 있어 합계 비교가 완전하지 않습니다. +

+ )} +
+ ); +} + +// 툴팁 — 시각 + 금액 function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) { if (!active || !payload?.length) return null; const p = payload[0].payload; return ( -
- {timeLabel(p.t)} - {won(p.price)} +
+

{stampLabel(p.t)}

+

{num(p.price)}원

); }