o2o-negosium-original/landing/app/components/sections/negotiation-demo.tsx
Haewon Kam 55e3ab02aa [fix] landing: 섹션 세로 리듬 통일 — 간격을 SectionHeading 이 소유
디자인 시스템이 색·활자만 소유하고 간격은 각 섹션 파일에 흩어져 있어,
같은 관계에 값이 여러 개 생겼다. 스크롤할 때 섹션마다 호흡이 달라지던 원인.

  eyebrow → title      12/20px      → 16px
  title → description  16/20/32px   → 20px (display 티어 32px)
  머리 → 본문          48~96px 5종  → 56px / 80px(md)
  컨트롤 → 본문        56/64px      → 48px
  리드 → CTA           56/48px      → 48px

- SectionHeading 이 리듬을 소유하고 HEADING_GAP·CONTROL_GAP·CTA_GAP·
  DISPLAY_LEAD_GAP 를 노출. 섹션 파일에서 mb-24 같은 값을 직접 쓰지 않는다.
- negotiation-demo·final-cta 가 Section/SectionHeading 을 우회해 머리를 직접
  조판하고 있었다 — 아이브로우 간격이 이 둘만 20px 이던 원인. 프리미티브로 환원.
- SectionHeading 이 eyebrow 없이도 빈 span 을 렌더해 유령 여백이 생기던 버그 수정.
- descriptionClassName 의 text-[17px] 중복 선언 제거. lead 변형이 이미 갖고 있는
  모바일 축소 단계(text-[16px] sm:text-[17px])를 지우고 있었다.
- reinforcement: text-balance 가 쉼표를 넘어 끊던 헤드라인을 <br /> 로 명시.

reinforcement 만 gap="none" 인데, 머리가 그리드 셀 안이라 마진이 상쇄되지 않아
items-center 정렬을 밀기 때문. 간격은 그리드 래퍼가 진다.

검증: tsc --noEmit 통과, 프로덕션 빌드 통과. DOM 실측으로 375/868/1440px
전 구간에서 머리→본문·eyebrow→title·컨트롤→본문 값 일치 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:22:24 +09:00

377 lines
16 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)
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)
setOffer(nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow)
}
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) // 에이전트가 부른 값에서 다시 시작
}
}, 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
return (
<Section id="how-it-works" bg="stage" width="lg">
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라
아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면
같은 관계에 두 번째 값이 생긴다. */}
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */}
<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">
<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>
</div>
<input
id="nego-offer"
type="range"
min={sliderMin}
max={sliderMax}
step={10_000}
value={offer}
onChange={(e) => setOffer(Number(e.target.value))}
disabled={role === "buyer" && turns.length > 0}
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={role === "buyer" && turns.length > 0}
>
{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>
)
}