feat(views): anonymous daily view counter with social-proof badge

- New table symbol_views_daily(code, view_date, views) via migration 004.
- POST /api/views/{code} UPSERTs and returns today_views; GET returns
  today + last_7d + trend (last 7 days).
- Client dedupes via localStorage (views:logged map keyed by KST date) so a
  given browser counts once per day per code; refresh/extra tabs don't inflate.
- PriceHero shows "오늘 N명이 봤어요" pill when viewsToday > 0.

Read-only social proof — Toss 의 종목 상세에서 익명 카운트로 진입 신호를 주는 패턴.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-owner
2026-05-28 23:56:36 +09:00
parent ef127ae2e8
commit ecf8b9112b
7 changed files with 248 additions and 2 deletions

View File

@@ -15,9 +15,20 @@ type Props = {
asOf?: string | null;
// 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시.
series?: number[];
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
viewsToday?: number | null;
};
export function PriceHero({ name, code, market, current, prev, asOf, series }: Props) {
export function PriceHero({
name,
code,
market,
current,
prev,
asOf,
series,
viewsToday,
}: Props) {
const change = current != null && prev != null ? current - prev : null;
const pct = change != null && prev ? (change / prev) * 100 : null;
const tone =
@@ -49,7 +60,17 @@ export function PriceHero({ name, code, market, current, prev, asOf, series }: P
</span>
)}
</div>
{asOf && <div className="mt-1 text-xs text-zinc-500"> {asOf}</div>}
<div className="mt-1 flex items-center gap-2 text-xs text-zinc-500">
{asOf && <span> {asOf}</span>}
{viewsToday != null && viewsToday > 0 && (
<span className="rounded-full border border-zinc-700/70 bg-zinc-900/60 px-2 py-0.5 text-[11px] text-zinc-300">
<span className="tabular-nums text-zinc-100">
{viewsToday.toLocaleString()}
</span>
</span>
)}
</div>
</div>
{series && series.length >= 2 && (