diff --git a/landing/app/components/sections/hero-dataflow.tsx b/landing/app/components/sections/hero-dataflow.tsx index eee2c8d..f04c45e 100644 --- a/landing/app/components/sections/hero-dataflow.tsx +++ b/landing/app/components/sections/hero-dataflow.tsx @@ -66,12 +66,14 @@ export function HeroDataFlow() { {/* 히어로 카피에는 진입 애니메이션을 걸지 않는다. 여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고, 광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 영상이 맡는다. */} - {/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다. - 네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */} + {/* 여기는 2톤을 쓰지 않는다. 예전 캔버스 배경은 하단 밴드에만 입자가 있어 헤드라인 + 뒤가 거의 단색이었고, 그때는 투명도 60% 로 낮춘 둘째 줄도 읽혔다. 배경이 영상으로 + 바뀌면서 밝은 요소가 글자 뒤로 지나가 그 대비가 무너졌다 — 배경이 움직이는 위에서는 + 가독성이 조판 장치보다 우선한다. 2톤은 최종 CTA 에 남아 있다(그쪽은 배경이 정적). */} 협력사 전원과 1:1로,
- 동시에 협상합니다. + 동시에 협상합니다.
{/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은 diff --git a/landing/app/components/sections/negotiation-demo.tsx b/landing/app/components/sections/negotiation-demo.tsx index a68416d..84c0ee3 100644 --- a/landing/app/components/sections/negotiation-demo.tsx +++ b/landing/app/components/sections/negotiation-demo.tsx @@ -39,6 +39,9 @@ export function NegotiationDemo() { const [cardsUsed, setCardsUsed] = useState(0) const [outcome, setOutcome] = useState("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(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 (
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라 @@ -219,21 +246,63 @@ export function NegotiationDemo() { {!done && (
+ {/* 에이전트 추천가. 값을 지어내지 않고 시뮬레이터가 실제로 쓰는 수치를 쓴다 — + 구매자는 anchor(결렬 없이 닿는 최저선, reachable = target >= anchor), + 공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 추천만 하고 + 확정은 사용자가 한다. 그게 이 데모가 보여줘야 할 관계다. */} +
+
+
에이전트 추천
+
{recommendReason}
+
+ +
+
- {won(round10(offer))}원 + {/* 직접 입력. 슬라이더만 두면 정확한 값을 못 넣는다 — 실제 구매 담당자는 + "얼마까지"라는 숫자를 이미 들고 온다. 타이핑 중에는 clamp 하지 않고 + blur/Enter 에서 확정한다. 입력 즉시 범위로 당기면 손가락과 싸운다. */} +
+ 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" + /> + 원 +
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" />
@@ -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" ? "이 값으로 제시하기" : "협상 시작"}