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) 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) setOffer(nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow) } 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) // 에이전트가 부른 값에서 다시 시작 } }, 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 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 && (
{won(round10(offer))}원
setOffer(Number(e.target.value))} disabled={role === "buyer" && turns.length > 0} 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 ( ) }