[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>
This commit is contained in:
Haewon Kam 2026-08-06 10:52:29 +09:00
parent 2888ff2c28
commit be039dd92b
2 changed files with 81 additions and 10 deletions

View File

@ -66,12 +66,14 @@ export function HeroDataFlow() {
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 영상이 맡는다. */}
{/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다.
네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */}
{/* 여기는 2톤을 쓰지 않는다. 예전 캔버스 배경은 하단 밴드에만 입자가 있어 헤드라인
뒤가 거의 단색이었고, 그때는 투명도 60% 로 낮춘 둘째 줄도 읽혔다. 배경이 영상으로
바뀌면서 밝은 요소가 글자 뒤로 지나가 그 대비가 무너졌다 — 배경이 움직이는 위에서는
가독성이 조판 장치보다 우선한다. 2톤은 최종 CTA 에 남아 있다(그쪽은 배경이 정적). */}
<Typography variant="stageDisplay">
<span className="text-white">협력사 전원과 1:1로,</span>
<br />
<span className="text-on-stage-soft/60">동시에 협상합니다.</span>
<span className="text-white">동시에 협상합니다.</span>
</Typography>
{/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은

View File

@ -39,6 +39,9 @@ export function NegotiationDemo() {
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)
@ -64,7 +67,9 @@ export function NegotiationDemo() {
setCardsUsed(0)
setOutcome("running")
setFinalPrice(nextItem.listPrice)
setOffer(nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow)
const start = nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow
setOffer(start)
setOfferText(String(start))
}
const pickRole = (r: Role) => {
@ -97,7 +102,9 @@ export function NegotiationDemo() {
setOutcome(res.outcome)
if (res.outcome === "running") {
setCardsUsed((c) => c + 1)
setOffer(res.price) // 에이전트가 부른 값에서 다시 시작
// 에이전트가 부른 값에서 다시 시작. 입력란도 같이 갱신해야 슬라이더와 숫자가 어긋나지 않는다.
setOffer(res.price)
setOfferText(String(res.price))
}
}, 620)
timers.current.push(id)
@ -123,6 +130,26 @@ export function NegotiationDemo() {
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 을 쓴다. 예전엔 여기서 직접 조판하느라
@ -219,21 +246,63 @@ export function NegotiationDemo() {
{!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>
<span className="text-[18px] font-bold tabular-nums">{won(round10(offer))}원</span>
{/* 직접 입력. 슬라이더만 두면 정확한 값을 못 넣는다 — 실제 구매 담당자는
"얼마까지"라는 숫자를 이미 들고 온다. 타이핑 중에는 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={offer}
onChange={(e) => setOffer(Number(e.target.value))}
disabled={role === "buyer" && turns.length > 0}
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">
@ -247,7 +316,7 @@ export function NegotiationDemo() {
size="lg"
className="w-full"
onClick={role === "seller" ? submitOffer : runBuyer}
disabled={role === "buyer" && turns.length > 0}
disabled={inputLocked}
>
{role === "seller" ? "이 값으로 제시하기" : "협상 시작"}
</Button>