레퍼런스(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>
372 lines
16 KiB
TypeScript
372 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 { 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" className="stage-bg text-on-stage py-24 md:py-32">
|
|
<div className="max-w-5xl mx-auto px-6">
|
|
<div className="text-center mb-14 md:mb-20">
|
|
<Typography variant="eyebrow" className="block mb-5 text-primary-on-stage">
|
|
Try it Yourself
|
|
</Typography>
|
|
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
|
|
자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */}
|
|
<Typography variant="title" as="h2">
|
|
<span className="text-white">직접 협상해 보세요.</span>
|
|
<br />
|
|
<span className="text-on-stage-soft/60">에이전트는 정해진 기준을 벗어나지 않습니다.</span>
|
|
</Typography>
|
|
</div>
|
|
|
|
{/* 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>
|
|
</div>
|
|
</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>
|
|
)
|
|
}
|