import { useEffect, useRef, useState } from "react" import { motion, useReducedMotion } from "motion/react" import { Check } from "lucide-react" import { DEMO_ITEMS, won } from "@/lib/negotiation-sim" import { EASE_OUT_EXPO } from "@/lib/motion" /* * 협상 리플레이 — 자동 재생 결과 화면. * * 예전에는 negotiation_annotated_3d.mp4(677K)를 썼다. 영상이라 두 가지를 못 고쳤다. * - 화자가 "아이마켓 구매 MD" 로 픽셀에 구워져 있었다. POC 고객사 이름이라 * 다른 MRO 회사에 영업할 때 쓸 수 없다. * - 색이 브랜드 팔레트 밖이었다. 실측하면 말풍선 #5A68CD, 목표가 라벨 #B96175, * 배지 #DB840B — 셋 다 토큰에 없는 색이고, 정작 브랜드의 핵심 대비 장치인 * 민트(#0FFFD6, 상대편 색)는 한 번도 안 쓰였다. * * 컴포넌트로 옮기면 둘 다 뿌리에서 사라진다. 이름은 상수고 색은 토큰이라, * 팔레트를 바꾸면 여기도 같이 바뀐다. 에셋 677K 도 0 이 된다. * * 숫자는 지어내지 않고 DEMO_ITEMS[0] 을 그대로 쓴다 — 위쪽 인터랙티브 데모와 같은 * 품목·같은 가격이라, 둘을 다 본 사람에게 앞뒤가 맞는다. */ const ITEM = DEMO_ITEMS[0] const FINAL = ITEM.target // 1,080,000 — 데모의 목표가와 동일 const SAVED = ITEM.listPrice - FINAL const RATE = (SAVED / ITEM.listPrice) * 100 type Line = { side: "buyer" | "supplier"; text: string; price: number } /* 화자는 "구매 담당자" 로 둔다. 회사명을 넣으면 그 회사 전용 자산이 되고, 특정 고객사 이름은 영업 대상이 바뀔 때마다 못 쓰게 된다. */ const LINES: Line[] = [ { side: "supplier", text: `원자재가 올라서 이번 분기는 ${won(ITEM.listPrice)}원이 최선입니다.`, price: ITEM.listPrice }, { side: "buyer", text: `같은 사양 시장 최저가가 ${won(ITEM.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`, price: ITEM.marketLow }, { side: "supplier", text: `공정상 한 번에 내리긴 어렵고, ${won(1_140_000)}원까지는 조정하겠습니다.`, price: 1_140_000 }, { side: "buyer", text: `목표가까지 얼마 남지 않았습니다. ${won(FINAL)}원이면 지금 바로 낙찰 처리하겠습니다.`, price: FINAL }, { side: "supplier", text: `좋습니다. ${won(FINAL)}원으로 맞추겠습니다.`, price: FINAL }, ] const STEP_MS = 1600 /** 마지막 줄 뒤 결과 카드가 머무는 시간. 짧으면 결론을 못 읽고 지나간다. */ const HOLD_MS = 3200 export function NegotiationReplay({ className }: { className?: string }) { const reduce = useReducedMotion() const [shown, setShown] = useState(reduce ? LINES.length : 0) const rootRef = useRef(null) const logRef = useRef(null) const [inView, setInView] = useState(false) /* 화면 밖에서는 돌리지 않는다. 페이지 최하단이라 대부분의 시간 동안 보이지 않는데 타이머만 계속 도는 건 배터리를 쓰는 일이다. */ useEffect(() => { const el = rootRef.current if (!el) return const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.25 }) io.observe(el) return () => io.disconnect() }, []) useEffect(() => { if (reduce || !inView) return const done = shown >= LINES.length const id = window.setTimeout(() => setShown((n) => (n >= LINES.length ? 0 : n + 1)), done ? HOLD_MS : STEP_MS) return () => clearTimeout(id) }, [shown, inView, reduce]) // 새 줄이 붙으면 바닥으로 붙인다. useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" }) }, [shown]) const current = shown > 0 ? LINES[shown - 1].price : ITEM.listPrice const settled = shown >= LINES.length /* 정가에서 목표가까지 얼마나 내려왔는지. 1 을 넘지 않게 자른다. */ const progress = Math.min(1, (ITEM.listPrice - current) / (ITEM.listPrice - FINAL)) return (
{/* 품목 + 목표가 — 구매 담당자가 위임한 기준이 무엇인지 먼저 보여준다. */}
{ITEM.name}
{ITEM.spec}
목표가
{won(FINAL)}원
{/* 현재 제안가 + 진행 막대. 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다. */}
현재 제안가 {won(current)}
{/* 대화. 높이를 고정해 줄이 늘어도 레이아웃이 튀지 않는다. */}
{LINES.slice(0, shown).map((line, i) => ( {line.side === "buyer" ? "구매 담당자" : "협력사"}
{line.text}
))}
{/* 낙찰 카드. 자리를 항상 차지하게 두고 내용만 켜고 끈다 — 나타날 때 아래 요소가 밀리면 페이지 전체가 주기적으로 흔들린다. */}
{settled && (
{/* 금액은 줄바꿈을 막는다. 카드 높이가 74px 로 고정이라(등장할 때 아래가 밀리지 않게 하려고) 한 줄이라도 접히면 잘린다. 좁으면 접히는 게 아니라 붙어야 한다. */}
협상 타결 → 낙찰
{won(FINAL)}원
절감액
{won(SAVED)}원 ▼{RATE.toFixed(1)}%
)}
) }