공급사 모드에서 슬라이더를 움직여도 첫 응수의 문장과 숫자가 똑같았다. 원인은 두 겹이다. - 근거 문장이 offer 를 참조하지 않았다. cardsUsed === 0 분기가 marketLow 만 읊었다. - CONCESSION[0] = 0 이라 부르는 값(ask)도 앵커로 고정이다. 둘이 겹쳐서, 얼마를 제시하든 화면이 전혀 반응하지 않는 것처럼 보였다. 값 고수는 유지한다 — 앵커를 지키는 게 이 에이전트의 성격이고, 제품이 파는 신뢰가 거기서 나온다. 대신 무엇에 대한 응수인지는 매번 달라지게 했다. 정가 이상 정가 그대로는 검토가 어렵습니다 + 시장 최저가 제시 최저가 위 제시가가 최저가보다 얼마 높은지 구체 수치로 최저가 이하 최저가 선까지 내려온 것을 인정 + 기준까지 남은 격차 입력란 숫자에 콤마 누락도 같이 고쳤다. 화면의 다른 금액은 전부 won() 인데 여기만 1018800 으로 나와 같은 값으로 안 읽혔다. 확정 시 콤마를 넣고 파싱할 때 벗긴다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
454 lines
21 KiB
TypeScript
454 lines
21 KiB
TypeScript
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<Step>("role")
|
|
const [role, setRole] = useState<Role>("seller")
|
|
const [item, setItem] = useState<DemoItem>(DEMO_ITEMS[0])
|
|
const [turns, setTurns] = useState<Turn[]>([])
|
|
const [cardsUsed, setCardsUsed] = useState(0)
|
|
const [outcome, setOutcome] = useState<Outcome>("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<HTMLDivElement>(null)
|
|
const timers = useRef<number[]>([])
|
|
|
|
// 재생 중인 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 (
|
|
<Section id="how-it-works" bg="stage" width="lg">
|
|
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라
|
|
아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면
|
|
같은 관계에 두 번째 값이 생긴다. */}
|
|
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
|
|
자동 줄바꿈에 맡기면 어절이 어색하게 끊긴다.
|
|
|
|
예전 카피는 "에이전트는 정해진 기준을 벗어나지 않습니다" 였다. FAQ 2번
|
|
("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로 끌어올린
|
|
것이었는데, 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었다.
|
|
게다가 부정문이라 안 하는 일을 약속했다 — 체험을 권하는 자리에는 맞지 않는다.
|
|
방어는 FAQ 에 그대로 남아 있으니 여기서는 능력을 말한다. */}
|
|
<SectionHeading
|
|
align="center"
|
|
tone="stage"
|
|
eyebrow="Try it Yourself"
|
|
title={
|
|
<>
|
|
<span className="text-white">사용할수록 진화하는 에이전틱 협상,</span>
|
|
<br />
|
|
<span className="text-on-stage-soft/60">직접 경험해 보세요!</span>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음
|
|
화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는
|
|
전환에는 맞지 않는 도구다. 나가는 연출 없이 바로 갈아끼우고 짧게 들어온다. */}
|
|
{step === "role" ? (
|
|
<motion.div
|
|
key="role"
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
|
|
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
|
>
|
|
<RoleCard
|
|
title="공급사로 해보기"
|
|
lead="직접 단가를 제시하면 에이전트가 응수합니다."
|
|
detail="협상 카드 3장 안에서 단가가 어디까지 조율되는지 확인하실 수 있습니다."
|
|
onClick={() => pickRole("seller")}
|
|
/>
|
|
<RoleCard
|
|
title="구매 담당자로 해보기"
|
|
lead="목표가만 정하면 에이전트가 협력사와 조율합니다."
|
|
detail="기준을 벗어난 목표가를 설정했을 때의 처리 방식도 함께 확인하실 수 있습니다."
|
|
onClick={() => pickRole("buyer")}
|
|
/>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="play"
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
|
|
className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12"
|
|
>
|
|
{/* 좌 — 품목·컨트롤·현재가 */}
|
|
<div className="lg:col-span-5 space-y-8">
|
|
<div className="flex flex-wrap gap-2">
|
|
{DEMO_ITEMS.map((it) => (
|
|
<button
|
|
key={it.id}
|
|
type="button"
|
|
onClick={() => pickItem(it)}
|
|
className={`px-4 py-2 rounded-full text-[13px] font-semibold transition-colors ${
|
|
it.id === item.id
|
|
? "bg-white text-stage"
|
|
: "bg-white/5 text-on-stage-soft/75 hover:bg-white/10 hover:text-white"
|
|
}`}
|
|
>
|
|
{it.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-[13px] text-on-stage-soft/55 mb-2">{item.spec}</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-[24px] font-medium text-on-stage-soft/55">₩</span>
|
|
<span className="text-[52px] md:text-[64px] font-extrabold tracking-[-0.045em] leading-none tabular-nums">
|
|
{won(finalPrice)}
|
|
</span>
|
|
</div>
|
|
<div className="h-7 mt-2">
|
|
{amount > 0 && (
|
|
<span className="text-[15px] font-semibold text-counter">
|
|
▼ {won(amount)}원 · {rate.toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!done && (
|
|
<div className="space-y-4">
|
|
{/* 에이전트 추천가. 값을 지어내지 않고 시뮬레이터가 실제로 쓰는 수치를 쓴다 —
|
|
구매자는 anchor(결렬 없이 닿는 최저선, reachable = target >= anchor),
|
|
공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 추천만 하고
|
|
확정은 사용자가 한다. 그게 이 데모가 보여줘야 할 관계다. */}
|
|
<div className="flex items-center justify-between gap-3 rounded-control border border-primary-on-stage/25 bg-primary-on-stage/10 px-4 py-3">
|
|
<div className="min-w-0">
|
|
<div className="text-[12px] font-semibold text-primary-on-stage mb-0.5">에이전트 추천</div>
|
|
<div className="text-[13px] text-on-stage-soft/75 break-keep">{recommendReason}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => commitOffer(recommended)}
|
|
disabled={inputLocked}
|
|
className="shrink-0 rounded-control bg-primary-on-stage/20 hover:bg-primary-on-stage/30 px-3 py-2 text-[13px] font-bold tabular-nums text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{won(recommended)}원 적용
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-baseline justify-between">
|
|
<label htmlFor="nego-offer" className="text-[14px] font-medium text-on-stage-soft/85">
|
|
{role === "seller" ? "제시 단가" : "목표 단가"}
|
|
</label>
|
|
{/* 직접 입력. 슬라이더만 두면 정확한 값을 못 넣는다 — 실제 구매 담당자는
|
|
"얼마까지"라는 숫자를 이미 들고 온다. 타이핑 중에는 clamp 하지 않고
|
|
blur/Enter 에서 확정한다. 입력 즉시 범위로 당기면 손가락과 싸운다. */}
|
|
<div className="flex items-baseline gap-1">
|
|
<input
|
|
id="nego-offer-text"
|
|
type="text"
|
|
inputMode="numeric"
|
|
aria-label={role === "seller" ? "제시 단가 직접 입력" : "목표 단가 직접 입력"}
|
|
value={offerText}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-[15px] font-semibold text-on-stage-soft/70">원</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
id="nego-offer"
|
|
type="range"
|
|
aria-label={role === "seller" ? "제시 단가 조절" : "목표 단가 조절"}
|
|
min={sliderMin}
|
|
max={sliderMax}
|
|
step={10_000}
|
|
value={Math.min(sliderMax, Math.max(sliderMin, offer))}
|
|
onChange={(e) => commitOffer(Number(e.target.value))}
|
|
disabled={inputLocked}
|
|
className="w-full accent-primary disabled:opacity-40"
|
|
/>
|
|
<div className="flex justify-between text-[12px] text-on-stage-soft/45">
|
|
<span>{won(sliderMin)}</span>
|
|
<span>{won(sliderMax)}</span>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="stage"
|
|
size="lg"
|
|
className="w-full"
|
|
onClick={role === "seller" ? submitOffer : runBuyer}
|
|
disabled={inputLocked}
|
|
>
|
|
{role === "seller" ? "이 값으로 제시하기" : "협상 시작"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{role === "seller" && (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[13px] text-on-stage-soft/55">협상 카드</span>
|
|
<div className="flex gap-1.5">
|
|
{Array.from({ length: MAX_CARDS }, (_, i) => (
|
|
<span
|
|
key={i}
|
|
className={`w-7 h-1.5 rounded-full ${i < MAX_CARDS - cardsUsed ? "bg-primary-on-stage" : "bg-white/15"}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우 — 협상 기록 */}
|
|
<div className="lg:col-span-7">
|
|
<div
|
|
ref={logRef}
|
|
className="h-[360px] md:h-[440px] overflow-y-auto scrollbar-none flex flex-col gap-4 pr-1"
|
|
>
|
|
{turns.length === 0 && (
|
|
<div className="m-auto text-center text-[15px] text-on-stage-soft/45 max-w-xs break-keep">
|
|
{role === "seller"
|
|
? "단가를 정해 제시하면 에이전트가 근거와 함께 응수합니다."
|
|
: "목표가를 정하고 협상을 시작하면 턴마다 재생됩니다."}
|
|
</div>
|
|
)}
|
|
|
|
{turns.map((turn) => (
|
|
<motion.div
|
|
key={turn.id}
|
|
initial={{ opacity: 0, y: 14 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35, ease: EASE_OUT_EXPO }}
|
|
className={`flex flex-col ${turn.side === "agent" ? "items-start" : "items-end"}`}
|
|
>
|
|
<span
|
|
className={`text-[12px] font-semibold mb-1.5 ${
|
|
turn.side === "agent" ? "text-primary-on-stage" : "text-counter"
|
|
}`}
|
|
>
|
|
{turn.side === "agent" ? "협상 에이전트" : role === "seller" ? "나 (협력사)" : "협력사"}
|
|
</span>
|
|
<div
|
|
className={`max-w-[88%] rounded-card px-5 py-4 text-[15px] leading-[1.6] break-keep ${
|
|
turn.side === "agent"
|
|
? "bg-primary text-white"
|
|
: "bg-counter-surface text-counter border border-counter/20"
|
|
}`}
|
|
>
|
|
{turn.text}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
{done && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
|
|
className="mt-6 border-t border-stage-line pt-6"
|
|
>
|
|
<Typography variant="cardTitle" className="text-white mb-1.5">
|
|
{outcome === "award"
|
|
? `낙찰 · ${won(amount)}원 절감`
|
|
: "개찰 — 낙찰자 미정으로 마감했습니다"}
|
|
</Typography>
|
|
<p className="text-[15px] text-on-stage-soft/70 leading-[1.6] break-keep mb-6">
|
|
{outcome === "award"
|
|
? `최초 제시가 ${won(item.listPrice)}원에서 ${rate.toFixed(1)}% 내려왔습니다. 실제 운영에서는 협력사 수만큼 이 협상이 동시에 진행됩니다.`
|
|
: "기준을 벗어나는 값은 억지로 맞추지 않고 담당자에게 넘깁니다. 결렬이 아니라 사람이 판단할 자리를 남기는 것입니다."}
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<Button href="#contact-section" variant="stage" size="lg" className="group">
|
|
<span>우리 품목으로 상담받기</span>
|
|
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
|
</Button>
|
|
<Button type="button" variant="stageGhost" size="lg" onClick={restart}>
|
|
<RotateCcw className="w-4 h-4" />
|
|
<span>역할 바꿔서 다시</span>
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
|
|
실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.
|
|
</p>
|
|
</Section>
|
|
)
|
|
}
|
|
|
|
function RoleCard({
|
|
title,
|
|
lead,
|
|
detail,
|
|
onClick,
|
|
}: {
|
|
title: string
|
|
lead: string
|
|
detail: string
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className="group text-left rounded-card border border-stage-line bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/25 transition-colors p-8 md:p-10"
|
|
>
|
|
<Typography variant="heading" as="h3" className="text-white mb-3">
|
|
{title}
|
|
</Typography>
|
|
<p className="text-[16px] text-on-stage-soft/85 leading-[1.6] break-keep mb-2">{lead}</p>
|
|
<p className="text-[14px] text-on-stage-soft/55 leading-[1.6] break-keep">{detail}</p>
|
|
<span className="mt-6 inline-flex items-center gap-1.5 text-[16px] font-semibold text-primary-on-stage">
|
|
시작하기
|
|
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|