o2o-negosium-original/landing/app/components/sections/negotiation-demo.tsx
Haewon Kam be039dd92b [feat] landing: 협상 데모에 에이전트 추천가·직접 입력 추가, 히어로 헤드라인 흰색 통일
데모 — 추천하고, 사용자가 고친다
슬라이더 하나뿐이라 정확한 값을 넣을 수 없었고, 에이전트가 추천하는 자리도 없었다.
"에이전트가 제안하면 사람이 확정한다"가 제품의 관계인데 UI 가 그걸 보여주지 못했다.

- 에이전트 추천가 + 근거 + 원클릭 적용. 값은 지어내지 않고 시뮬레이터가 실제로 쓰는
  수치에서 가져온다 — 구매자는 anchor(reachable = target >= anchor 인 최저선),
  공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 지어낸 숫자를 추천이라 부르면
  사용자가 그대로 넣었을 때 결과가 설명되지 않는다.
- 직접 입력란 추가. 실제 구매 담당자는 "얼마까지"라는 숫자를 이미 들고 온다.
  타이핑 중에는 clamp 하지 않고 blur/Enter 에서 확정한다 — 입력 즉시 범위로 당기면
  "1" 을 치는 순간 튕겨서 손가락과 싸운다.
- 슬라이더·직접입력·추천적용이 commitOffer 한 곳을 거친다. 경로가 셋이면 값이 셋 된다.
- 에이전트 응수 후 offer 만 갱신하고 입력란은 두던 버그 수정. 슬라이더와 숫자가 어긋났다.
- 잠금 조건(role === buyer && turns.length > 0)을 inputLocked 로 묶었다.

히어로 헤드라인
"동시에 협상합니다." 를 흰색으로. 예전 캔버스는 하단 밴드에만 입자가 있어 헤드라인
뒤가 거의 단색이었고 그때는 투명도 60% 도 읽혔다. 배경이 영상으로 바뀌며 밝은 요소가
글자 뒤로 지나가 대비가 무너졌다 — 배경이 움직이는 위에서는 가독성이 조판 장치보다
우선한다. 2톤은 배경이 정적인 최종 CTA 에 남아 있다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:52:29 +09:00

452 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(String(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(String(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(String(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)
setOfferText(String(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))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commitOffer(Number(offerText))
}
}}
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>
)
}