325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react"
|
||
import { AnimatePresence, motion } from "motion/react"
|
||
import { Bot, Handshake, ShieldCheck } from "lucide-react"
|
||
|
||
import { PriceSparkline } from "@/components/ui/price-sparkline"
|
||
import { SlateRenderer } from "@/components/ui/slate-renderer"
|
||
import { Typography } from "@/components/ui/typography"
|
||
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||
import type { DialogueStep } from "@/types"
|
||
|
||
/** 스크롤 연동 sticky 협상 데모 — 스크롤 진행률에 따라 대화가 쌓이고 단가가 내려간다. */
|
||
export function NegotiationConsole() {
|
||
// -1 = 아직 섹션 진입 전(온보딩 가이드 표시)
|
||
const [activeStep, setActiveStep] = useState(-1)
|
||
const [animatedPrice, setAnimatedPrice] = useState(1200000)
|
||
const sectionRef = useRef<HTMLDivElement>(null)
|
||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 섹션 내 스크롤 진행률 → 대화 스텝 매핑
|
||
useEffect(() => {
|
||
const handleScroll = () => {
|
||
if (!sectionRef.current) return
|
||
const rect = sectionRef.current.getBoundingClientRect()
|
||
const totalScrollable = rect.height - window.innerHeight
|
||
if (totalScrollable <= 0) return
|
||
|
||
const scrolled = -rect.top
|
||
if (scrolled < 0) {
|
||
setActiveStep(-1)
|
||
return
|
||
}
|
||
|
||
const clampedRatio = Math.max(0, Math.min(scrolled / totalScrollable, 0.99))
|
||
setActiveStep(Math.floor(clampedRatio * NEGOTIATION_STEPS.length))
|
||
}
|
||
|
||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||
handleScroll()
|
||
return () => window.removeEventListener("scroll", handleScroll)
|
||
}, [])
|
||
|
||
// 단가 오도미터 애니메이션 (500ms ease-out)
|
||
useEffect(() => {
|
||
const targetPrice = activeStep === -1 ? 1200000 : NEGOTIATION_STEPS[activeStep].price
|
||
if (animatedPrice === targetPrice) return
|
||
|
||
const start = animatedPrice
|
||
const duration = 500
|
||
const startTime = performance.now()
|
||
|
||
const animate = (currentTime: number) => {
|
||
const progress = Math.min((currentTime - startTime) / duration, 1)
|
||
const easeProgress = progress * (2 - progress)
|
||
setAnimatedPrice(Math.round(start + (targetPrice - start) * easeProgress))
|
||
if (progress < 1) requestAnimationFrame(animate)
|
||
}
|
||
|
||
requestAnimationFrame(animate)
|
||
}, [activeStep])
|
||
|
||
// 새 말풍선이 붙으면 채팅 영역을 바닥으로 스크롤
|
||
useEffect(() => {
|
||
chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: "smooth" })
|
||
}, [activeStep])
|
||
|
||
return (
|
||
<section
|
||
id="how-it-works"
|
||
ref={sectionRef}
|
||
className="relative bg-surface text-ink w-full font-sans border-t border-line h-[240vh] md:h-[280vh]"
|
||
>
|
||
<div className="sticky top-0 h-screen flex items-center justify-center w-full overflow-hidden px-6 md:px-8">
|
||
{/* 섹션 헤딩 — sticky 상단 고정 (모바일은 공간상 생략) */}
|
||
<div className="absolute top-24 left-0 right-0 text-center hidden md:block">
|
||
<Typography variant="eyebrow" className="block">
|
||
Live Replay
|
||
</Typography>
|
||
<Typography variant="heading" as="h2" className="mt-1">
|
||
봇은 물러서지 않습니다
|
||
</Typography>
|
||
</div>
|
||
|
||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center w-full">
|
||
{/* 좌: 실시간 단가 패널 */}
|
||
<div className="md:col-span-5 flex flex-col justify-center space-y-6 text-center md:text-left">
|
||
<div>
|
||
<span className="text-[11px] font-bold text-ink-muted uppercase tracking-wider block mb-1">실시간 협상 단가</span>
|
||
<div className="flex items-baseline gap-1.5 justify-center md:justify-start">
|
||
<span className="text-3xl font-bold text-primary">₩</span>
|
||
<span className="text-4xl md:text-5xl font-black tracking-tight text-primary">
|
||
{animatedPrice.toLocaleString()}
|
||
</span>
|
||
</div>
|
||
{/* 시작가 대비 누적 절감 델타 — 높이 고정으로 레이아웃 점프 방지 */}
|
||
<div className="h-6 mt-1.5">
|
||
{animatedPrice < START_PRICE && (
|
||
<span className="inline-flex items-baseline gap-1.5 text-positive font-bold text-sm">
|
||
▼ {(START_PRICE - animatedPrice).toLocaleString()}원
|
||
<span className="text-xs font-semibold">
|
||
(−{(((START_PRICE - animatedPrice) / START_PRICE) * 100).toFixed(1)}%)
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 계단식 하락 스파크라인 + 목표가 기준선 */}
|
||
<div>
|
||
<PriceSparkline
|
||
prices={NEGOTIATION_STEPS.map((step) => step.price)}
|
||
targetPrice={TARGET_PRICE}
|
||
progress={activeStep < 0 ? 0 : (activeStep + 1) / NEGOTIATION_STEPS.length}
|
||
/>
|
||
<div className="flex justify-between mt-2">
|
||
<Typography variant="micro">시작가 {START_PRICE.toLocaleString()}</Typography>
|
||
<Typography variant="micro" className="text-primary">
|
||
목표가 {TARGET_PRICE.toLocaleString()}
|
||
</Typography>
|
||
</div>
|
||
<Typography variant="caption" className="mt-3">
|
||
실제 협상 화면을 재구성한 데모입니다.
|
||
</Typography>
|
||
</div>
|
||
|
||
<div className="h-10">
|
||
<AnimatePresence mode="wait">
|
||
{activeStep !== -1 && NEGOTIATION_STEPS[activeStep]?.badge && (
|
||
<motion.div
|
||
key={NEGOTIATION_STEPS[activeStep].badge}
|
||
initial={{ scale: 0.95, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
exit={{ scale: 0.95, opacity: 0 }}
|
||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-positive-soft text-positive border border-positive/15 text-xs font-bold"
|
||
>
|
||
<ShieldCheck className="w-4 h-4 shrink-0" />
|
||
{NEGOTIATION_STEPS[activeStep].badge}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 우: 대화 말풍선 (봇 좌 / 파트너 우) */}
|
||
<div className="md:col-span-7 flex flex-col justify-end space-y-5 h-[340px] md:h-[420px] relative overflow-hidden">
|
||
<div className="absolute inset-0 bg-gradient-to-b from-surface via-transparent to-transparent h-12 pointer-events-none z-10" />
|
||
|
||
<div
|
||
ref={chatContainerRef}
|
||
className="flex flex-col gap-5 overflow-y-auto pr-1 scrollbar-none w-full h-full justify-center"
|
||
>
|
||
<AnimatePresence mode="wait" initial={false}>
|
||
{activeStep === -1 ? (
|
||
<motion.div
|
||
key="onboarding-guide"
|
||
initial={{ opacity: 0, y: 15, scale: 0.97 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: -15, scale: 0.97 }}
|
||
transition={{ duration: 0.4 }}
|
||
className="flex flex-col items-center justify-center text-center p-8 border border-dashed border-line-strong rounded-[28px] bg-white shadow-[0_4px_20px_rgba(0,0,0,0.01)] max-w-md mx-auto"
|
||
>
|
||
<div className="w-12 h-12 rounded-full bg-primary-soft flex items-center justify-center text-primary mb-4">
|
||
<Bot className="w-6 h-6 animate-pulse" />
|
||
</div>
|
||
<h3 className="text-base font-bold text-ink mb-1.5">실시간 AI 협상 대화</h3>
|
||
<p className="text-xs text-ink-soft font-semibold leading-relaxed break-keep">
|
||
화면을 스크롤하면 실시간 단가 조율 대화가 시작됩니다.
|
||
</p>
|
||
<div className="mt-4 flex items-center gap-1.5 text-[11px] font-bold text-primary animate-bounce">
|
||
<span>마우스 휠 스크롤하기</span>
|
||
<span>↓</span>
|
||
</div>
|
||
</motion.div>
|
||
) : (
|
||
<div className="flex flex-col gap-5 w-full mt-auto">
|
||
{NEGOTIATION_STEPS.slice(0, activeStep + 1).map((item, idx) => {
|
||
const isBot = item.editorNodes[0].sender === "bot"
|
||
|
||
return (
|
||
<motion.div
|
||
key={idx}
|
||
initial={{ opacity: 0, y: 15, scale: 0.97 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: -10, scale: 0.97 }}
|
||
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
|
||
className={`flex flex-col ${isBot ? "items-start" : "items-end"} w-full`}
|
||
>
|
||
<div className={`flex items-center gap-1.5 mb-1 text-[11px] font-bold text-ink-muted ${isBot ? "" : "flex-row-reverse"}`}>
|
||
<span className={`flex items-center gap-1 ${isBot ? "text-primary" : "text-ink-soft"}`}>
|
||
{isBot ? (
|
||
<>
|
||
<Bot className="w-3.5 h-3.5" />
|
||
<span>NEGOTIUM BOT</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Handshake className="w-3.5 h-3.5" />
|
||
<span>PARTNER</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
className={`max-w-[85%] rounded-[20px] p-4 text-sm leading-relaxed font-semibold shadow-[0_2px_8px_rgba(0,0,0,0.02)] transition-all duration-300 ${
|
||
isBot ? "bg-primary-soft text-ink rounded-tl-none" : "bg-white text-ink rounded-tr-none"
|
||
}`}
|
||
>
|
||
<SlateRenderer nodes={item.editorNodes} />
|
||
</div>
|
||
</motion.div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// 시작가(첫 제시가)·목표가 — 좌측 패널 델타·스파크라인 기준값
|
||
const START_PRICE = 1200000
|
||
const TARGET_PRICE = 1080000
|
||
|
||
// 데모 대화 시나리오 — 1,200,000원 제시에서 1,050,000원 낙찰까지 6스텝
|
||
const NEGOTIATION_STEPS: DialogueStep[] = [
|
||
{
|
||
step: 1,
|
||
price: 1200000,
|
||
editorNodes: [
|
||
{
|
||
type: 'paragraph',
|
||
sender: 'supplier',
|
||
children: [
|
||
{ text: '협력사', bold: true },
|
||
{ text: ' : ' },
|
||
{ text: '현재 글로벌 원재료 상승 요인으로 제안할 수 있는 최선의 단가는 1,200,000원입니다. 이 이하로는 마진 확보가 어렵습니다.' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
step: 2,
|
||
price: 1150000,
|
||
editorNodes: [
|
||
{
|
||
type: 'paragraph',
|
||
sender: 'bot',
|
||
children: [
|
||
{ text: 'AI 흥정 봇', bold: true },
|
||
{ text: ' : ' },
|
||
{ text: '제시해주신 1,200,000원은 당사 타 유사 품목 이력 및 시중 원가 인덱스 데이터 대비 약 8.3% 높게 책정되어 있습니다. ', italic: true },
|
||
{ text: '상호 호혜적 장기 계약 체결을 전제로 조율가 범위를 반영해 제안해 드립니다.', code: true },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
step: 3,
|
||
price: 1150000,
|
||
editorNodes: [
|
||
{
|
||
type: 'paragraph',
|
||
sender: 'supplier',
|
||
children: [
|
||
{ text: '협력사', bold: true },
|
||
{ text: ' : ' },
|
||
{ text: '제조 공정상 급격한 인하는 무리가 있으나, 상생 협력 차원에서 1,150,000원까지는 즉시 조정해 드릴 용의가 있습니다.' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
step: 4,
|
||
price: 1080000,
|
||
editorNodes: [
|
||
{
|
||
type: 'paragraph',
|
||
sender: 'bot',
|
||
children: [
|
||
{ text: 'AI 흥정 봇', bold: true },
|
||
{ text: ' : ' },
|
||
{ text: '적극적인 협조에 감사드립니다. 만약 연간 최소 발주 수량을 보증하고 공급망 일정을 다소 유연화해주신다면, 목표가인 1,080,000원 선까지 맞출 수 있을까요?', italic: true },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
step: 5,
|
||
price: 1050000,
|
||
editorNodes: [
|
||
{
|
||
type: 'paragraph',
|
||
sender: 'supplier',
|
||
children: [
|
||
{ text: '협력사', bold: true },
|
||
{ text: ' : ' },
|
||
{ text: '좋습니다. 제안하신 연간 개런티 확보 및 대금 현금 결제 기한 단축을 승인해 주시는 조건으로, ' },
|
||
{ text: '최종 조율가 1,050,000원으로 맞춰서 계약을 체결하겠습니다.', bold: true },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
step: 6,
|
||
price: 1050000,
|
||
badge: '낙찰 성공 · 12.5% 예산 절감',
|
||
editorNodes: [
|
||
{
|
||
type: 'paragraph',
|
||
sender: 'bot',
|
||
children: [
|
||
{ text: 'AI 흥정 봇', bold: true },
|
||
{ text: ' : ' },
|
||
{ text: '최종 합의 접수 완료 — 가이드 상한가(1,200,000원) 대비 합의 낙찰가 1,050,000원으로 최종 계약 승인 처리 완료되었습니다. ', bold: true },
|
||
{ text: '본 흥정 마일스톤 및 단가 타결 히스토리는 사내 투명성 보증을 위해 보존 기록됩니다.', code: true },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
]
|