import { useEffect, useRef, useState } from "react" import { motion } from "motion/react" import { ArrowRight, RotateCcw } from "lucide-react" import { Button } from "@/components/ui/button" import { Section } from "@/components/ui/section" import { SectionHeading } from "@/components/ui/section-heading" import { Typography } from "@/components/ui/typography" import { EASE_OUT_EXPO } from "@/lib/motion" import { DEMO_ITEMS, MAX_CARDS, respondToOffer, round10, savings, simulateBuyerRun, won, type DemoItem, type Outcome, type Role, type Turn, } from "@/lib/negotiation-sim" type Step = "role" | "play" /** * 랜딩 인터랙티브 협상 데모. * * 스크롤로 재생만 되던 기존 콘솔을 대체한다. 구경이 아니라 사용자가 한쪽을 맡아 * 실제로 값을 제시하고 판정을 받는다 — 광고로 들어온 잠재고객이 제품을 만져보는 자리다. * * 판정 규칙은 lib/negotiation-sim 에 있고 이 파일은 화면만 맡는다. */ export function NegotiationDemo() { const [step, setStep] = useState("role") const [role, setRole] = useState("seller") const [item, setItem] = useState(DEMO_ITEMS[0]) const [turns, setTurns] = useState([]) const [cardsUsed, setCardsUsed] = useState(0) const [outcome, setOutcome] = useState("running") const [offer, setOffer] = useState(DEMO_ITEMS[0].listPrice) /* 입력란의 원문. offer 와 따로 두는 이유는 타이핑 도중 상태다 — 숫자 하나로 묶으면 "1" 을 치는 순간 범위 밖이라 튕겨서 사용자와 싸우게 된다. 확정은 blur/Enter 에서 한다. */ const [offerText, setOfferText] = useState(won(DEMO_ITEMS[0].listPrice)) const [finalPrice, setFinalPrice] = useState(DEMO_ITEMS[0].listPrice) const logRef = useRef(null) const timers = useRef([]) // 재생 중인 buyer 시나리오 타이머는 리셋·언마운트 때 반드시 걷어낸다. const clearTimers = () => { timers.current.forEach(clearTimeout) timers.current = [] } useEffect(() => clearTimers, []) /** 역할 선택 화면으로 완전히 되돌린다. 같은 역할로만 다시 하면 나머지 절반은 영영 안 보게 되므로, 다시 해보기는 역할부터 고르게 하는 게 맞다. */ const restart = () => { reset() setStep("role") } const reset = (nextItem = item, nextRole = role) => { clearTimers() setTurns([]) setCardsUsed(0) setOutcome("running") setFinalPrice(nextItem.listPrice) const start = nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow setOffer(start) setOfferText(won(start)) } const pickRole = (r: Role) => { setRole(r) reset(item, r) setStep("play") } const pickItem = (it: DemoItem) => { setItem(it) reset(it, role) } // 새 말풍선이 붙으면 기록을 바닥으로 붙인다. useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" }) }, [turns]) /** seller — 사용자가 공급사가 되어 값을 제시하고 에이전트가 응수한다. */ const submitOffer = () => { if (outcome !== "running") return const priced = round10(offer) const mine: Turn = { id: turns.length, side: "counterpart", text: `${won(priced)}원까지는 맞춰드릴 수 있습니다.`, price: priced } setTurns((prev) => [...prev, mine]) const res = respondToOffer(item, priced, cardsUsed) const id = window.setTimeout(() => { setTurns((prev) => [...prev, { id: prev.length, side: "agent", text: res.text, price: res.price }]) setFinalPrice(res.outcome === "award" ? priced : res.price) setOutcome(res.outcome) if (res.outcome === "running") { setCardsUsed((c) => c + 1) // 에이전트가 부른 값에서 다시 시작. 입력란도 같이 갱신해야 슬라이더와 숫자가 어긋나지 않는다. setOffer(res.price) setOfferText(won(res.price)) } }, 620) timers.current.push(id) } /** buyer — 목표가만 정하면 에이전트가 알아서 붙는 걸 지켜본다. */ const runBuyer = () => { if (turns.length > 0) return const target = round10(offer) const run = simulateBuyerRun(item, target) run.turns.forEach((turn, i) => { const id = window.setTimeout(() => { setTurns((prev) => [...prev, turn]) if (turn.price) setFinalPrice(turn.price) if (i === run.turns.length - 1) setOutcome(run.outcome) }, 500 + i * 900) timers.current.push(id) }) } const done = outcome !== "running" const { amount, rate } = savings(item, finalPrice) const sliderMin = role === "seller" ? round10(item.anchor * 0.94) : round10(item.anchor * 0.94) const sliderMax = role === "seller" ? item.listPrice : item.marketLow /* 협상이 시작된 뒤 구매자는 목표가를 못 바꾼다 — 목표가를 정하고 위임하는 게 제품의 전제다. 공급사는 카드가 남아 있는 한 계속 제시할 수 있다. */ const inputLocked = role === "buyer" && turns.length > 0 /* 추천가는 시뮬레이터가 실제로 쓰는 수치에서 가져온다. 지어낸 숫자를 추천이라 부르면 사용자가 그대로 넣었을 때 결과가 설명되지 않는다. */ const recommended = role === "seller" ? item.marketLow : item.anchor const recommendReason = role === "seller" ? `같은 사양 시장 최저가 선입니다. 여기서부터 에이전트가 근거를 들고 응수합니다.` : `결렬 없이 도달 가능한 최저선입니다. 더 낮추면 협상이 성사되지 않습니다.` /** 슬라이더·직접입력·추천적용이 모두 거쳐가는 단일 확정 경로. 범위로 당기고 10원 단위로 맞춘다. */ const commitOffer = (raw: number) => { if (inputLocked) return const next = Number.isFinite(raw) && raw > 0 ? round10(Math.min(sliderMax, Math.max(sliderMin, raw))) : offer setOffer(next) /* 확정된 값은 콤마를 넣어 보여준다 — 화면의 다른 금액이 전부 won() 이라 여기만 1018800 으로 나오면 같은 값으로 안 읽힌다. 타이핑 중에는 숫자만 남긴다. */ setOfferText(won(next)) } return (
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라 아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면 같은 관계에 두 번째 값이 생긴다. */} {/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다. 자동 줄바꿈에 맡기면 어절이 어색하게 끊긴다. 예전 카피는 "에이전트는 정해진 기준을 벗어나지 않습니다" 였다. FAQ 2번 ("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로 끌어올린 것이었는데, 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었다. 게다가 부정문이라 안 하는 일을 약속했다 — 체험을 권하는 자리에는 맞지 않는다. 방어는 FAQ 에 그대로 남아 있으니 여기서는 능력을 말한다. */} 사용할수록 진화하는 에이전틱 협상,
직접 경험해 보세요! } /> {/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음 화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는 전환에는 맞지 않는 도구다. 나가는 연출 없이 바로 갈아끼우고 짧게 들어온다. */} {step === "role" ? ( pickRole("seller")} /> pickRole("buyer")} /> ) : ( {/* 좌 — 품목·컨트롤·현재가 */}
{DEMO_ITEMS.map((it) => ( ))}
{item.spec}
₩ {won(finalPrice)}
{amount > 0 && ( ▼ {won(amount)}원 · {rate.toFixed(1)}% )}
{!done && (
{/* 에이전트 추천가. 값을 지어내지 않고 시뮬레이터가 실제로 쓰는 수치를 쓴다 — 구매자는 anchor(결렬 없이 닿는 최저선, reachable = target >= anchor), 공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 추천만 하고 확정은 사용자가 한다. 그게 이 데모가 보여줘야 할 관계다. */}
에이전트 추천
{recommendReason}
{/* 직접 입력. 슬라이더만 두면 정확한 값을 못 넣는다 — 실제 구매 담당자는 "얼마까지"라는 숫자를 이미 들고 온다. 타이핑 중에는 clamp 하지 않고 blur/Enter 에서 확정한다. 입력 즉시 범위로 당기면 손가락과 싸운다. */}
setOfferText(e.target.value.replace(/[^\d]/g, ""))} onBlur={() => commitOffer(Number(offerText.replace(/[^\d]/g, "")))} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() commitOffer(Number(offerText.replace(/[^\d]/g, ""))) } }} disabled={inputLocked} className="w-32 bg-transparent border-0 border-b-2 border-stage-line focus:border-primary-on-stage hover:border-on-stage-muted rounded-none px-0 py-1 text-right text-[18px] font-bold tabular-nums text-white outline-none transition-colors disabled:opacity-40" /> 원
commitOffer(Number(e.target.value))} disabled={inputLocked} className="w-full accent-primary disabled:opacity-40" />
{won(sliderMin)} {won(sliderMax)}
)} {role === "seller" && (
협상 카드
{Array.from({ length: MAX_CARDS }, (_, i) => ( ))}
)}
{/* 우 — 협상 기록 */}
{turns.length === 0 && (
{role === "seller" ? "단가를 정해 제시하면 에이전트가 근거와 함께 응수합니다." : "목표가를 정하고 협상을 시작하면 턴마다 재생됩니다."}
)} {turns.map((turn) => ( {turn.side === "agent" ? "협상 에이전트" : role === "seller" ? "나 (협력사)" : "협력사"}
{turn.text}
))}
{done && ( {outcome === "award" ? `낙찰 · ${won(amount)}원 절감` : "개찰 — 낙찰자 미정으로 마감했습니다"}

{outcome === "award" ? `최초 제시가 ${won(item.listPrice)}원에서 ${rate.toFixed(1)}% 내려왔습니다. 실제 운영에서는 협력사 수만큼 이 협상이 동시에 진행됩니다.` : "기준을 벗어나는 값은 억지로 맞추지 않고 담당자에게 넘깁니다. 결렬이 아니라 사람이 판단할 자리를 남기는 것입니다."}

)}
)}

실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.

) } function RoleCard({ title, lead, detail, onClick, }: { title: string lead: string detail: string onClick: () => void }) { return ( ) }