[feat] landing: 최종 CTA 결과 화면을 영상에서 컴포넌트로 — 브랜드 팔레트·중립 화자
negotiation_annotated_3d.mp4(677K)를 NegotiationReplay 컴포넌트로 대체했다.
영상이라 두 가지를 못 고쳤고, 둘 다 픽셀에 구워진 것이 원인이었다.
화자
"아이마켓 구매 MD" 가 박혀 있었다. POC 고객사 이름이라 다른 MRO 회사에 영업할 때
쓸 수 없다. 이제 "구매 담당자" 로, 회사명 없이 역할만 쓴다 — 상수라 언제든 바꾼다.
색
실측하면 말풍선 #5A68CD, 목표가 라벨 #B96175, 와일드카드 배지 #DB840B 로 셋 다
토큰에 없는 색이었고, 정작 브랜드의 핵심 대비 장치인 민트(#0FFFD6, 상대편 색)는
한 번도 쓰이지 않았다. 이제 토큰만 쓴다 — 우리 쪽 파랑, 상대편 민트.
진행 막대도 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다.
부수 효과
- 다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제가 사라졌다.
카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다.
- 677K + 포스터 85K → 0. 어느 해상도에서도 선명하다(영상은 840x1260 고정이었다).
- role="img" + aria-label 로 스크린리더가 결론을 읽는다. 영상은 못 읽었다.
숫자는 지어내지 않고 DEMO_ITEMS[0] 을 그대로 쓴다. 위쪽 인터랙티브 데모와 같은
품목·같은 가격이라 둘을 다 본 사람에게 앞뒤가 맞는다.
구현 메모
- 화면 밖에서는 타이머를 멈춘다(IntersectionObserver). 페이지 최하단이라
대부분의 시간 동안 보이지 않는데 계속 도는 건 배터리를 쓰는 일이다.
- prefers-reduced-motion 이면 최종 상태를 정지 표시한다.
- 낙찰 카드 자리를 h-[74px] 로 항상 확보하고 내용만 켜고 끈다. 나타날 때 아래가
밀리면 페이지가 주기적으로 흔들린다. 대신 금액은 whitespace-nowrap — 고정 높이라
한 줄이라도 접히면 잘린다(실제로 "1,080,000" 뒤 "원" 이 접혔다).
검증: 1280x900 카드 319x74 · 375x812 285x74, 슬롯 초과·가로 넘침 없음.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3214e4f782
commit
d3e33b10f4
@ -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() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 결과 화면. 영상 배경이 밝은 회색(#EEF4F5)이라 무대 위에 그냥 놓으면 구멍처럼 뜬다.
|
||||
모서리를 깎고 그림자를 깔아 "떠 있는 물체"로 만들면 밝기 차이가 의도로 읽힌다.
|
||||
폰 프레임은 영상에 내장돼 있어서 PhoneFrame 으로 감싸지 않는다 — 감싸면 이중 프레임이 된다. */}
|
||||
{/* 결과 화면. 예전엔 negotiation_annotated_3d.mp4 였는데 컴포넌트로 바꿨다 —
|
||||
영상은 화자("아이마켓 구매 MD")와 색이 픽셀에 구워져 있어 둘 다 못 고쳤다.
|
||||
다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제도 같이 사라져서,
|
||||
카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다. */}
|
||||
<motion.div {...mediaFadeUp} className="lg:col-span-5">
|
||||
<div className="mx-auto max-w-[300px] lg:max-w-none rounded-card overflow-hidden ring-1 ring-white/10 shadow-[0_30px_80px_-30px_rgba(3,6,15,0.95)]">
|
||||
<video
|
||||
className="w-full h-auto block"
|
||||
src="/gifs/negotiation_annotated_3d.mp4"
|
||||
poster="/gifs/negotiation_annotated_3d.jpg"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
aria-label="협상 타결 후 낙찰가와 절감액이 표시된 화면"
|
||||
/>
|
||||
</div>
|
||||
<NegotiationReplay className="mx-auto max-w-[360px] lg:max-w-none" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
179
landing/app/components/ui/negotiation-replay.tsx
Normal file
179
landing/app/components/ui/negotiation-replay.tsx
Normal file
@ -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<HTMLDivElement>(null)
|
||||
const logRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label={`협상 리플레이. 최초 제시가 ${won(ITEM.listPrice)}원에서 ${won(FINAL)}원으로 낙찰되어 ${won(SAVED)}원 절감된 예시입니다.`}
|
||||
>
|
||||
<div className="rounded-card border border-stage-line bg-stage-raised/70 backdrop-blur-sm p-5 sm:p-6">
|
||||
{/* 품목 + 목표가 — 구매 담당자가 위임한 기준이 무엇인지 먼저 보여준다. */}
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-bold text-on-stage truncate">{ITEM.name}</div>
|
||||
<div className="text-[12px] text-on-stage-muted">{ITEM.spec}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-[11px] font-semibold text-on-stage-muted">목표가</div>
|
||||
<div className="text-[14px] font-bold tabular-nums text-on-stage">{won(FINAL)}원</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 제안가 + 진행 막대. 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다. */}
|
||||
<div className="mb-5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold text-on-stage-muted">현재 제안가</span>
|
||||
<span className="text-[22px] font-extrabold tabular-nums tracking-[-0.02em] text-on-stage">
|
||||
{won(current)}
|
||||
<span className="text-[14px] font-semibold text-on-stage-muted ml-0.5">원</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-linear-to-r from-primary-on-stage to-counter"
|
||||
animate={{ width: `${progress * 100}%` }}
|
||||
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대화. 높이를 고정해 줄이 늘어도 레이아웃이 튀지 않는다. */}
|
||||
<div ref={logRef} className="h-[210px] overflow-hidden space-y-3 scrollbar-none">
|
||||
{LINES.slice(0, shown).map((line, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={reduce ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
className={`flex flex-col ${line.side === "buyer" ? "items-start" : "items-end"}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[11px] font-semibold mb-1 ${
|
||||
line.side === "buyer" ? "text-primary-on-stage" : "text-counter"
|
||||
}`}
|
||||
>
|
||||
{line.side === "buyer" ? "구매 담당자" : "협력사"}
|
||||
</span>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-card px-3.5 py-2.5 text-[13px] leading-[1.55] break-keep ${
|
||||
line.side === "buyer"
|
||||
? "bg-primary text-white"
|
||||
: "bg-counter-surface text-counter border border-counter/20"
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 낙찰 카드. 자리를 항상 차지하게 두고 내용만 켜고 끈다 — 나타날 때 아래 요소가
|
||||
밀리면 페이지 전체가 주기적으로 흔들린다. */}
|
||||
<div className="mt-4 h-[74px]">
|
||||
{settled && (
|
||||
<motion.div
|
||||
initial={reduce ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
|
||||
className="h-full rounded-card border border-counter/25 bg-counter-surface px-3.5 py-3 flex items-center gap-2.5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-counter/15 text-counter flex items-center justify-center shrink-0">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
{/* 금액은 줄바꿈을 막는다. 카드 높이가 74px 로 고정이라(등장할 때 아래가 밀리지
|
||||
않게 하려고) 한 줄이라도 접히면 잘린다. 좁으면 접히는 게 아니라 붙어야 한다. */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-counter mb-0.5 whitespace-nowrap">협상 타결 → 낙찰</div>
|
||||
<div className="text-[16px] font-extrabold tabular-nums text-on-stage whitespace-nowrap">{won(FINAL)}원</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-auto">
|
||||
<div className="text-[11px] font-semibold text-on-stage-muted mb-0.5 whitespace-nowrap">절감액</div>
|
||||
<div className="text-[15px] font-extrabold tabular-nums text-counter whitespace-nowrap">
|
||||
{won(SAVED)}원 <span className="text-[12px]">▼{RATE.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user