[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:
parent
2888ff2c28
commit
be039dd92b
@ -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 고정)을 못 쓴다. 대신 간격만은
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user