diff --git a/landing/app/components/sections/final-cta.tsx b/landing/app/components/sections/final-cta.tsx index c44419b..9154272 100644 --- a/landing/app/components/sections/final-cta.tsx +++ b/landing/app/components/sections/final-cta.tsx @@ -2,6 +2,7 @@ import { motion } from "motion/react" import { ArrowRight } from "lucide-react" import { Button } from "@/components/ui/button" +import { NegotiationReplay } from "@/components/ui/negotiation-replay" import { Section } from "@/components/ui/section" import { CTA_GAP, SectionHeading } from "@/components/ui/section-heading" import { useFadeUp } from "@/lib/motion" @@ -58,23 +59,12 @@ export function FinalCTA() { - {/* 결과 화면. 영상 배경이 밝은 회색(#EEF4F5)이라 무대 위에 그냥 놓으면 구멍처럼 뜬다. - 모서리를 깎고 그림자를 깔아 "떠 있는 물체"로 만들면 밝기 차이가 의도로 읽힌다. - 폰 프레임은 영상에 내장돼 있어서 PhoneFrame 으로 감싸지 않는다 — 감싸면 이중 프레임이 된다. */} + {/* 결과 화면. 예전엔 negotiation_annotated_3d.mp4 였는데 컴포넌트로 바꿨다 — + 영상은 화자("아이마켓 구매 MD")와 색이 픽셀에 구워져 있어 둘 다 못 고쳤다. + 다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제도 같이 사라져서, + 카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다. */} -
-
+
diff --git a/landing/app/components/ui/negotiation-replay.tsx b/landing/app/components/ui/negotiation-replay.tsx new file mode 100644 index 0000000..d2f46df --- /dev/null +++ b/landing/app/components/ui/negotiation-replay.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from "react" +import { motion, useReducedMotion } from "motion/react" +import { Check } from "lucide-react" + +import { DEMO_ITEMS, won } from "@/lib/negotiation-sim" +import { EASE_OUT_EXPO } from "@/lib/motion" + +/* + * 협상 리플레이 — 자동 재생 결과 화면. + * + * 예전에는 negotiation_annotated_3d.mp4(677K)를 썼다. 영상이라 두 가지를 못 고쳤다. + * - 화자가 "아이마켓 구매 MD" 로 픽셀에 구워져 있었다. POC 고객사 이름이라 + * 다른 MRO 회사에 영업할 때 쓸 수 없다. + * - 색이 브랜드 팔레트 밖이었다. 실측하면 말풍선 #5A68CD, 목표가 라벨 #B96175, + * 배지 #DB840B — 셋 다 토큰에 없는 색이고, 정작 브랜드의 핵심 대비 장치인 + * 민트(#0FFFD6, 상대편 색)는 한 번도 안 쓰였다. + * + * 컴포넌트로 옮기면 둘 다 뿌리에서 사라진다. 이름은 상수고 색은 토큰이라, + * 팔레트를 바꾸면 여기도 같이 바뀐다. 에셋 677K 도 0 이 된다. + * + * 숫자는 지어내지 않고 DEMO_ITEMS[0] 을 그대로 쓴다 — 위쪽 인터랙티브 데모와 같은 + * 품목·같은 가격이라, 둘을 다 본 사람에게 앞뒤가 맞는다. + */ + +const ITEM = DEMO_ITEMS[0] +const FINAL = ITEM.target // 1,080,000 — 데모의 목표가와 동일 +const SAVED = ITEM.listPrice - FINAL +const RATE = (SAVED / ITEM.listPrice) * 100 + +type Line = { side: "buyer" | "supplier"; text: string; price: number } + +/* 화자는 "구매 담당자" 로 둔다. 회사명을 넣으면 그 회사 전용 자산이 되고, + 특정 고객사 이름은 영업 대상이 바뀔 때마다 못 쓰게 된다. */ +const LINES: Line[] = [ + { side: "supplier", text: `원자재가 올라서 이번 분기는 ${won(ITEM.listPrice)}원이 최선입니다.`, price: ITEM.listPrice }, + { side: "buyer", text: `같은 사양 시장 최저가가 ${won(ITEM.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`, price: ITEM.marketLow }, + { side: "supplier", text: `공정상 한 번에 내리긴 어렵고, ${won(1_140_000)}원까지는 조정하겠습니다.`, price: 1_140_000 }, + { side: "buyer", text: `목표가까지 얼마 남지 않았습니다. ${won(FINAL)}원이면 지금 바로 낙찰 처리하겠습니다.`, price: FINAL }, + { side: "supplier", text: `좋습니다. ${won(FINAL)}원으로 맞추겠습니다.`, price: FINAL }, +] + +const STEP_MS = 1600 +/** 마지막 줄 뒤 결과 카드가 머무는 시간. 짧으면 결론을 못 읽고 지나간다. */ +const HOLD_MS = 3200 + +export function NegotiationReplay({ className }: { className?: string }) { + const reduce = useReducedMotion() + const [shown, setShown] = useState(reduce ? LINES.length : 0) + const rootRef = useRef(null) + const logRef = useRef(null) + const [inView, setInView] = useState(false) + + /* 화면 밖에서는 돌리지 않는다. 페이지 최하단이라 대부분의 시간 동안 보이지 않는데 + 타이머만 계속 도는 건 배터리를 쓰는 일이다. */ + useEffect(() => { + const el = rootRef.current + if (!el) return + const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.25 }) + io.observe(el) + return () => io.disconnect() + }, []) + + useEffect(() => { + if (reduce || !inView) return + const done = shown >= LINES.length + const id = window.setTimeout(() => setShown((n) => (n >= LINES.length ? 0 : n + 1)), done ? HOLD_MS : STEP_MS) + return () => clearTimeout(id) + }, [shown, inView, reduce]) + + // 새 줄이 붙으면 바닥으로 붙인다. + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" }) + }, [shown]) + + const current = shown > 0 ? LINES[shown - 1].price : ITEM.listPrice + const settled = shown >= LINES.length + /* 정가에서 목표가까지 얼마나 내려왔는지. 1 을 넘지 않게 자른다. */ + const progress = Math.min(1, (ITEM.listPrice - current) / (ITEM.listPrice - FINAL)) + + return ( +
+
+ {/* 품목 + 목표가 — 구매 담당자가 위임한 기준이 무엇인지 먼저 보여준다. */} +
+
+
{ITEM.name}
+
{ITEM.spec}
+
+
+
목표가
+
{won(FINAL)}원
+
+
+ + {/* 현재 제안가 + 진행 막대. 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다. */} +
+
+ 현재 제안가 + + {won(current)} + 원 + +
+
+ +
+
+ + {/* 대화. 높이를 고정해 줄이 늘어도 레이아웃이 튀지 않는다. */} +
+ {LINES.slice(0, shown).map((line, i) => ( + + + {line.side === "buyer" ? "구매 담당자" : "협력사"} + +
+ {line.text} +
+
+ ))} +
+ + {/* 낙찰 카드. 자리를 항상 차지하게 두고 내용만 켜고 끈다 — 나타날 때 아래 요소가 + 밀리면 페이지 전체가 주기적으로 흔들린다. */} +
+ {settled && ( + +
+ +
+ {/* 금액은 줄바꿈을 막는다. 카드 높이가 74px 로 고정이라(등장할 때 아래가 밀리지 + 않게 하려고) 한 줄이라도 접히면 잘린다. 좁으면 접히는 게 아니라 붙어야 한다. */} +
+
협상 타결 → 낙찰
+
{won(FINAL)}원
+
+
+
절감액
+
+ {won(SAVED)}원 ▼{RATE.toFixed(1)}% +
+
+
+ )} +
+
+
+ ) +}