280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } from "react"
|
|
import { AnimatePresence, motion } from "motion/react"
|
|
import { Bot, Handshake, ShieldCheck } from "lucide-react"
|
|
|
|
import { SlateRenderer } from "@/components/ui/slate-renderer"
|
|
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(1200)
|
|
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 ? 1200 : 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">
|
|
<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">
|
|
<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">
|
|
<span className="text-3xl font-bold text-primary">₩</span>
|
|
<span className="text-5xl md:text-6xl font-black tracking-tight text-primary">
|
|
{animatedPrice.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</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-emerald-50 text-positive border border-emerald-100 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-end" : "items-start"} 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-tr-none" : "bg-white text-ink rounded-tl-none"
|
|
}`}
|
|
>
|
|
<SlateRenderer nodes={item.editorNodes} />
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// 데모 대화 시나리오 — 1,200원 제시에서 1,050원 낙찰까지 6스텝
|
|
const NEGOTIATION_STEPS: DialogueStep[] = [
|
|
{
|
|
step: 1,
|
|
price: 1200,
|
|
editorNodes: [
|
|
{
|
|
type: 'paragraph',
|
|
sender: 'supplier',
|
|
children: [
|
|
{ text: '협력사', bold: true },
|
|
{ text: ' : ' },
|
|
{ text: '현재 글로벌 원재료 상승 요인으로 제안할 수 있는 최선의 단가는 1,200원입니다. 이 이하로는 마진 확보가 어렵습니다.' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
step: 2,
|
|
price: 1150,
|
|
editorNodes: [
|
|
{
|
|
type: 'paragraph',
|
|
sender: 'bot',
|
|
children: [
|
|
{ text: 'AI 흥정 봇', bold: true },
|
|
{ text: ' : ' },
|
|
{ text: '제시해주신 1,200원은 당사 타 유사 품목 이력 및 시중 원가 인덱스 데이터 대비 약 8.3% 높게 책정되어 있습니다. ', italic: true },
|
|
{ text: '상호 호혜적 장기 계약 체결을 전제로 조율가 범위를 반영해 제안해 드립니다.', code: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
step: 3,
|
|
price: 1150,
|
|
editorNodes: [
|
|
{
|
|
type: 'paragraph',
|
|
sender: 'supplier',
|
|
children: [
|
|
{ text: '협력사', bold: true },
|
|
{ text: ' : ' },
|
|
{ text: '제조 공정상 급격한 인하는 무리가 있으나, 상생 협력 차원에서 1,150원까지는 즉시 조정해 드릴 용의가 있습니다.' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
step: 4,
|
|
price: 1080,
|
|
editorNodes: [
|
|
{
|
|
type: 'paragraph',
|
|
sender: 'bot',
|
|
children: [
|
|
{ text: 'AI 흥정 봇', bold: true },
|
|
{ text: ' : ' },
|
|
{ text: '적극적인 협조에 감사드립니다. 만약 연간 최소 발주 수량을 보증하고 공급망 일정을 다소 유연화해주신다면, 목표가인 1,080원 선까지 맞출 수 있을까요?', italic: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
step: 5,
|
|
price: 1050,
|
|
editorNodes: [
|
|
{
|
|
type: 'paragraph',
|
|
sender: 'supplier',
|
|
children: [
|
|
{ text: '협력사', bold: true },
|
|
{ text: ' : ' },
|
|
{ text: '좋습니다. 제안하신 연간 개런티 확보 및 대금 현금 결제 기한 단축을 승인해 주시는 조건으로, ' },
|
|
{ text: '최종 조율가 1,050원으로 맞춰서 계약을 체결하겠습니다.', bold: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
step: 6,
|
|
price: 1050,
|
|
badge: '낙찰 성공 · 12.5% 예산 절감',
|
|
editorNodes: [
|
|
{
|
|
type: 'paragraph',
|
|
sender: 'bot',
|
|
children: [
|
|
{ text: 'AI 흥정 봇', bold: true },
|
|
{ text: ' : ' },
|
|
{ text: '최종 합의 접수 완료 — 가이드 상한가(1,200원) 대비 합의 낙찰가 1,050원으로 최종 계약 승인 처리 완료되었습니다. ', bold: true },
|
|
{ text: '본 흥정 마일스톤 및 단가 타결 히스토리는 사내 투명성 보증을 위해 보존 기록됩니다.', code: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|