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)이었다 — 프리미티브를 우회하면 같은 관계에 두 번째 값이 생긴다. */} {/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다. 자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */} 직접 협상해 보세요.
에이전트는 정해진 기준을 벗어나지 않습니다. } /> {/* 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 ( ) }