o2o-negosium-original/landing/app/lib/negotiation-sim.ts
Haewon Kam 6785c8a6f4 [feat] landing: 랜딩 v2 — 다크 무대 히어로·인터랙티브 협상 데모·디자인 토큰 재정립
레퍼런스(statworx·jitter·infinith) 실측값으로 디자인 시스템을 다시 잡고,
스크롤 재생형 콘솔을 사용자가 직접 협상하는 데모로 교체했다.

디자인 토큰
- 타이포: 굵기·크기·자간을 함께 조정(섹션 제목 44→64px/600→800/자간 -0.042em).
  한글 행간은 1.07 — 라틴 기준 0.95 를 쓰면 네모틀이 꽉 차 줄이 닿는다.
- 모서리: 12가지로 흩어져 있던 값을 card 8px / control 4px / full 셋으로 수렴.
  20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의 가장 강한 신호였다.
- 다크 무대 토큰 신설(stage/on-stage/counter) + 배경 그라데이션.
- primary-on-stage(#7C8CFF) 추가 — #0101F3 은 무대 배경 대비 2.0:1 로
  WCAG 최소(4.5:1)의 절반도 안 돼 다크 위에서 읽히지 않았다.
- 영문 디스플레이 서체 Playfair Display self-host(latin 서브셋 39KB).

히어로
- 배경 캔버스가 실제 파이프라인을 그린다: 수집→분류→대조→산정→협상→학습.
  분류 단계는 거래 품목(시트)·사용자·협력사로 갈라지고 라벨이 붙는다.
- z 좌표 + 원근 투영. 군집마다 z 평면을 하나씩 줘 격자는 온전한 채 깊이만 생긴다.
- 순환 링(정원, 점선 궤도 회전). 진행도는 ref 로 공유해 60fps 리렌더를 피한다.
- 카피에 진입 애니메이션을 걸지 않는다 — LCP 요소라 opacity:0 으로 시작하면
  하이드레이션 전까지 첫 화면이 빈다.

협상 데모 (신규)
- 역할 선택(공급사/구매 담당자) → 품목·단가 제시 → 턴 협상 → 낙찰/개찰 → 상담 신청.
- 판정 규칙은 실제 엔진과 동일: 앵커링가 이하 낙찰, 협상 카드 3장, 소진 시 개찰.
- 로직은 lib/negotiation-sim.ts 순수 함수로 분리 — 나중에 agent 서비스 연동 시
  컴포넌트를 건드리지 않는다.

카피 감사
- "봇" 21곳 → "에이전트"(로봇 아이콘 3개 포함). 대리인은 조달 실무의 정당한 역할이다.
- "악역은 봇이", "단가를 깎는 악역" 제거 — 협력사를 적으로 규정하는 프레임은
  같은 페이지 FAQ(공급사 만족도 82%)와 모순이고, 동반성장 평가를 받는
  대기업 구매팀 결재 라인에 올릴 수 없다.
- 번역투 최상급(초정밀·압도적·완벽히·최적의)과 데모 대화문 전면 재작성.

버그 수정
- 헤더가 흰 배경 위에 흰 글자 버튼을 그리던 문제. onStage 를 스크롤 수치로 계산해
  하이드레이션 전 스크롤 복원 시 초기값이 굳었다 → IntersectionObserver 로 교체.
- 버튼 transition-all → transition-colors (variant 교체 시 배경 플리커).
- 폼 입력 14px → 16px. iOS 사파리는 16px 미만 입력에 포커스하면 화면을 확대해
  모바일 폼 이탈을 만든다.

정리
- 대체된 히어로 3종·기존 협상 콘솔 삭제(약 760줄).
- 라벨 아이콘·알약 배지·blur(120px) 글로우 블롭 제거.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:08:37 +09:00

178 lines
6.4 KiB
TypeScript

/**
* 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용).
*
* 실제 negotium 엔진(agent 서비스)의 판정 규칙을 그대로 옮겼다. 데모가 제품과 다른
* 규칙으로 돌면 상담 자리에서 거짓말이 되기 때문에, 단순화는 하되 규칙은 바꾸지 않는다.
*
* - 앵커링가(anchor) 이하 제시는 무조건 수락한다.
* - 앵커 위면 협상 카드를 한 장 쓰고 역제안한다. 카드는 3장.
* - 카드를 다 쓰고도 앵커 위면 개찰(낙찰자 미정 마감)이다. 결렬이 아니다.
*
* React 를 모른다. 나중에 서버의 실엔진(/api/demo/negotiate)으로 갈아끼울 때
* 이 파일의 함수 시그니처만 유지하면 컴포넌트는 손대지 않아도 된다.
*/
export type Role = "buyer" | "seller"
export type DemoItem = {
id: string
name: string
spec: string
/** 협력사 최초 제시가 */
listPrice: number
/** LPS 인터넷 최저가 — 에이전트가 근거로 인용한다 */
marketLow: number
/** 앵커링가. 이 이하면 무조건 낙찰 */
anchor: number
/** 견적 생성 때 정한 목표가 */
target: number
}
export const MAX_CARDS = 3
export const DEMO_ITEMS: DemoItem[] = [
{
id: "glove",
name: "니트릴 코팅 안전장갑",
spec: "1,000켤레 · 월 정기",
listPrice: 1_200_000,
marketLow: 1_110_000,
anchor: 1_020_000,
target: 1_080_000,
},
{
id: "paper",
name: "A4 복사용지 80g",
spec: "500박스 · 분기 발주",
listPrice: 8_750_000,
marketLow: 8_200_000,
anchor: 7_900_000,
target: 8_100_000,
},
{
id: "oil",
name: "산업용 윤활유 20L",
spec: "300통 · 반기 발주",
listPrice: 5_400_000,
marketLow: 5_050_000,
anchor: 4_850_000,
target: 4_980_000,
},
]
export type Turn = {
id: number
side: "agent" | "counterpart"
text: string
/** 이 턴에서 테이블에 올라온 가격 */
price?: number
}
export type Outcome = "running" | "award" | "open"
/** 협상에서 실제로 오가는 조건들. 가격만 깎는 게 아니라는 걸 보여주는 장치. */
const LEVERS = ["연간 물량 보증", "납기 2주 연장", "대금 지급일 15일 단축"] as const
/** 10원 단위 반올림 — 실제 엔진의 앵커링 정돈 규칙과 같다. */
export const round10 = (n: number) => Math.round(n / 10) * 10
export const won = (n: number) => n.toLocaleString("ko-KR")
/**
* 에이전트의 양보 곡선 — 이 데모의 성격을 결정하는 단 하나의 숫자 뭉치다.
*
* 카드를 쓸수록 앵커에서 상대 제시가 쪽으로 얼마나 올라올지를 정한다.
* 0 이면 끝까지 앵커를 고수하고, 1 이면 즉시 상대 제시가를 받는다.
* 앞 카드를 낮게 잡을수록 강하게 버티는 에이전트가 된다.
*/
const CONCESSION = [0, 0.34, 0.62]
/** 협력사(사용자) 제시가에 대한 에이전트의 판정. seller 모드의 핵심. */
export function respondToOffer(
item: DemoItem,
offer: number,
cardsUsed: number,
): { outcome: Outcome; price: number; text: string } {
if (offer <= item.anchor) {
return {
outcome: "award",
price: offer,
text: `${won(offer)}원으로 확정하겠습니다. 목표가 ${won(item.target)}원 대비 ${won(
item.target - offer,
)}원 낮습니다. 주고받은 제안은 전부 기록에 남습니다.`,
}
}
if (cardsUsed >= MAX_CARDS) {
return {
outcome: "open",
price: offer,
text: `${won(offer)}원은 이번 견적의 낙찰 기준을 넘습니다. 협상 카드를 다 썼으니 이 건은 개찰로 마감하고 담당자에게 넘기겠습니다.`,
}
}
const ask = round10(item.anchor + (offer - item.anchor) * CONCESSION[cardsUsed])
const lever = LEVERS[cardsUsed]
const reason =
cardsUsed === 0
? `같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다.`
: `${won(offer)}원과 저희 기준 사이가 아직 ${won(offer - ask)}원 남았습니다.`
return {
outcome: "running",
price: ask,
text: `${reason} ${won(ask)}원까지 맞춰주시면 ${lever}으로 보전해 드리겠습니다.`,
}
}
/**
* buyer 모드 — 사용자가 목표가만 정하면 에이전트가 협력사와 알아서 붙는다.
* 전체 턴을 미리 만들어 두고 화면에서 하나씩 재생한다.
*/
export function simulateBuyerRun(item: DemoItem, target: number): { turns: Turn[]; outcome: Outcome; finalPrice: number } {
const turns: Turn[] = []
let id = 0
const push = (side: Turn["side"], text: string, price?: number) => turns.push({ id: id++, side, text, price })
push("counterpart", `원자재가 올라서 이번 분기는 ${won(item.listPrice)}원이 최선입니다.`, item.listPrice)
// 목표가가 앵커보다 낮으면 협력사가 받아들일 수 없는 구간이다.
// 에이전트는 기준 밖으로 나가지 않으므로 개찰로 끝난다 — 제품의 안전장치를 보여주는 케이스다.
const reachable = target >= item.anchor
push(
"agent",
`같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`,
item.marketLow,
)
const mid = round10((item.listPrice + Math.max(target, item.anchor)) / 2)
push("counterpart", `공정상 한 번에 내리긴 어렵고, ${won(mid)}원까지는 조정하겠습니다.`, mid)
if (!reachable) {
push(
"agent",
`목표가 ${won(target)}원은 협력사가 받아들일 수 있는 선 아래입니다. 무리하게 밀지 않고 이 건은 개찰로 마감하겠습니다.`,
mid,
)
return { turns, outcome: "open", finalPrice: mid }
}
push("agent", `${won(target)}원이면 연 12회 정기 발주로 확정하겠습니다. 대신 납기는 2주 여유를 드리겠습니다.`, target)
push("counterpart", `정기 발주와 결제 조건을 지켜주신다면 ${won(target)}원으로 맞추겠습니다.`, target)
push(
"agent",
`${won(target)}원으로 확정했습니다. 최초 제시가 대비 ${won(item.listPrice - target)}원 절감입니다.`,
target,
)
return { turns, outcome: "award", finalPrice: target }
}
/** 절감액·절감률. 결과 화면과 상담 신청 연결에 쓴다. */
export function savings(item: DemoItem, finalPrice: number) {
const amount = Math.max(0, item.listPrice - finalPrice)
return { amount, rate: (amount / item.listPrice) * 100 }
}