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:
parent
a306ad4cf3
commit
479cb862b1
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user