feat(lps-admin): 상품·가격 — '몰별 최저가'+'검증 링크' 를 몰별 가격 비교로 통합

두 카드가 사실 같은 데이터(by_mall)를 다르게 그린 것이라 하나로 합침.
상위 N개(3/5/10/전체 선택)를 순위·쇼핑몰·상품명·가격·최저가 대비 델타·
링크 한 줄로 보여주고 가격 비교 막대를 얹어 한눈에 비교 가능.

- MallEntry 타입 필드명 버그 수정(mall/url → mall_name/detail_url/name)
  — 기존 몰별 최저가 차트에 몰 이름이 안 보이던 원인. 이제 표시됨.
- 상위 N 은 프리셋 칩(by_mall 이 몰별 dedup 소집합이라 자유입력보다 적합).
- '검색어'는 항목별로 없어 각 몰의 상품명(title)으로 대체 — 같은 상품
  검증 역할.
- 모바일 가로 스크롤 해소: 상단 네비 overflow-x-auto 가 페이지를 늘리던
  것(루트 w-full 누락)과 master-detail 그리드 자식 min-w-0 누락 동반 수정.
- 실기동 검증: G마켓 56,880/플랜88 59,900 렌더·칩 토글·데스크톱/모바일
  hscroll 0·콘솔 에러 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-14 10:57:07 +09:00
parent a306ad4cf3
commit 479cb862b1
3 changed files with 98 additions and 57 deletions

View File

@ -81,13 +81,15 @@ export interface ProductListRes {
items: ProductItem[]; items: ProductItem[];
} }
/** 몰별 최저가 스냅샷 원소(price_history.by_mall) — summarize_by_mall 출력과 정합. */
export interface MallEntry { export interface MallEntry {
mall?: string;
source?: string; source?: string;
mall_name?: string;
price?: number; price?: number;
shipping_fee?: number | null; shipping_fee?: number | null;
shipping_type?: string | null; shipping_type?: string | null;
url?: string; name?: string;
detail_url?: string;
} }
export interface PricePoint { export interface PricePoint {

View File

@ -18,9 +18,9 @@ export default function Layout() {
const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 }); const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 });
const up = health.data === true; const up = health.data === true;
return ( return (
<div className="mx-auto flex min-h-screen max-w-[1280px] flex-col md:flex-row"> <div className="mx-auto flex min-h-screen w-full max-w-[1280px] flex-col md:flex-row">
{/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */} {/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */}
<aside className="w-full shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5"> <aside className="w-full min-w-0 shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5">
<div className="flex items-center justify-between md:block"> <div className="flex items-center justify-between md:block">
<div className="flex items-center gap-2.5 px-2 md:mb-6"> <div className="flex items-center gap-2.5 px-2 md:mb-6">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span> <span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span>

View File

@ -1,12 +1,12 @@
/** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 최저가 + 재검색. */ /** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 가격 비교 + 재검색. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { import {
Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts"; } from "recharts";
import { get, post } from "../api/client"; import { get, post } from "../api/client";
import type { PriceHistoryRes, ProductItem, ProductListRes, SearchRes } from "../api/types"; import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading } from "../components/ui"; import { Card, Empty, ErrorNote, Loading } from "../components/ui";
import { dateShort, timeAgo, won } from "../lib/format"; import { dateShort, timeAgo, won } from "../lib/format";
@ -37,8 +37,8 @@ export default function Products() {
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button> <button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form> </form>
<div className="grid gap-4 lg:grid-cols-[400px_1fr]"> <div className="grid min-w-0 gap-4 lg:grid-cols-[400px_1fr]">
<Card title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순"> <Card className="min-w-0" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
{list.isPending && <Loading />} {list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />} {list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>} {list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>}
@ -87,11 +87,10 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) })); const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
const latest = history.data?.points.at(-1); const latest = history.data?.points.at(-1);
const malls = (latest?.by_mall ?? []).filter((m) => m.price != null)
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)).slice(0, 8);
return ( return (
<div className="space-y-4"> // min-w-0: 그리드 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지
<div className="min-w-0 space-y-4">
<Card <Card
title={<>가격 추이 — {product.display_name || product.product_code}</>} title={<>가격 추이 — {product.display_name || product.product_code}</>}
hint={ hint={
@ -137,51 +136,91 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
)} )}
</Card> </Card>
{latest && ( {latest && <MallCompare latest={latest} />}
<div className="grid gap-4 md:grid-cols-2">
<Card title="몰별 최저가 (최근 검색)" hint={dateShort(latest.triggered_at)}>
{malls.length === 0 ? (
<Empty>몰별 데이터 없음</Empty>
) : (
<ResponsiveContainer width="100%" height={Math.max(120, malls.length * 34)}>
<BarChart data={malls} layout="vertical" margin={{ top: 0, right: 56, bottom: 0, left: 8 }}>
<XAxis type="number" hide domain={[0, "dataMax"]} />
<YAxis type="category" dataKey="mall" width={88} tick={{ fill: "var(--color-ink-700)", fontSize: 11.5 }} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => [won(v), "최저가"]} />
<Bar dataKey="price" barSize={14} radius={[0, 4, 4, 0]}
label={{ position: "right", fill: "var(--color-ink-500)", fontSize: 11, formatter: (v: number) => v.toLocaleString("ko-KR") }}>
{malls.map((m, i) => (
<Cell key={i} fill={m.source === "naver" ? "var(--color-naver)" : m.source === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Card>
<Card title="검증 링크 (최근 검색)">
<ul className="space-y-2 text-[13px]">
{latest.naver_url && (
<li>
<a href={latest.naver_url} target="_blank" rel="noreferrer" className="font-semibold text-primary-600 hover:underline">
네이버 — {won(latest.naver)} ↗
</a>
<div className="truncate text-[11px] text-ink-400">{latest.naver_name}</div>
</li>
)}
{latest.coupang_url && (
<li>
<a href={latest.coupang_url} target="_blank" rel="noreferrer" className="font-semibold text-primary-600 hover:underline">
쿠팡 — {won(latest.coupang)} ↗
</a>
<div className="truncate text-[11px] text-ink-400">{latest.coupang_name}</div>
</li>
)}
{!latest.naver_url && !latest.coupang_url && <Empty>링크 없음</Empty>}
</ul>
</Card>
</div>
)}
</div> </div>
); );
} }
// ── 몰별 가격 비교(최근 검색) — 기존 '몰별 최저가' + '검증 링크' 를 하나로.
// by_mall(몰별 최저가, 가격 오름차순)을 상위 N개 표로: 순위·쇼핑몰·상품명·가격(+배송)·최저가 대비·링크.
const TOP_N_PRESETS = [3, 5, 10] as const;
function MallCompare({ latest }: { latest: PricePoint }) {
const [topN, setTopN] = useState<number | "all">(5);
const malls = (latest.by_mall ?? [])
.filter((m) => m.price != null)
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
const shown = topN === "all" ? malls : malls.slice(0, topN);
const cheapest = malls[0]?.price ?? 0;
const maxPrice = Math.max(1, ...shown.map((m) => m.price ?? 0));
return (
<Card
title="몰별 가격 비교 (최근 검색)"
hint={
<span className="flex items-center gap-2">
<span className="hidden text-ink-400 sm:inline">{dateShort(latest.triggered_at)}</span>
<span role="tablist" aria-label="상위 N개" className="flex rounded-lg border border-line-200 p-0.5">
{TOP_N_PRESETS.map((n) => (
<button key={n} role="tab" aria-selected={topN === n} onClick={() => setTopN(n)}
className={`rounded-md px-2 py-0.5 text-[11px] font-semibold ${topN === n ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{n}
</button>
))}
<button role="tab" aria-selected={topN === "all"} onClick={() => setTopN("all")}
className={`rounded-md px-2 py-0.5 text-[11px] font-semibold ${topN === "all" ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
전체
</button>
</span>
</span>
}>
{shown.length === 0 ? (
<Empty>몰별 데이터 없음</Empty>
) : (
<>
<ul className="space-y-2.5">
{shown.map((m, i) => {
const price = m.price ?? 0;
const delta = price - cheapest;
const src = m.source === "naver" ? "var(--color-naver)" : m.source === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
return (
<li key={i} className="grid grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1">
{/* 순위 */}
<span className={`tnum grid h-6 w-6 place-items-center rounded-full text-[12px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}>
{i + 1}
</span>
{/* 쇼핑몰 + 상품명 */}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: src }} aria-hidden />
<span className="truncate text-[13px] font-semibold">{m.mall_name || m.source}</span>
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1.5 text-[10px] font-bold text-ok-600">최저</span>}
</div>
<div className="truncate text-[11px] text-ink-400">{m.name}</div>
</div>
{/* 가격 + 최저가 대비 + 링크 */}
<div className="text-right">
<div className="tnum text-[14px] font-bold">{won(price)}</div>
<div className="tnum text-[11px] text-ink-400">
{delta === 0 ? "최저가" : `+${delta.toLocaleString("ko-KR")}원`}
{m.detail_url && (
<a href={m.detail_url} target="_blank" rel="noreferrer" className="ml-1.5 font-semibold text-primary-600 hover:underline">열기 ↗</a>
)}
</div>
</div>
{/* 가격 비교 막대(전체 폭, 최고가 기준 상대 길이) */}
<div className="col-span-3 h-1.5 overflow-hidden rounded bg-surface-2" aria-hidden>
<div className="h-full rounded" style={{ width: `${(price / maxPrice) * 100}%`, background: src }} />
</div>
</li>
);
})}
</ul>
{topN !== "all" && malls.length > shown.length && (
<p className="mt-2 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 표시 — '전체'로 모두 보기</p>
)}
</>
)}
</Card>
);
}