레퍼런스(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>
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { useState } from "react"
|
|
import { AnimatePresence, motion } from "motion/react"
|
|
import { Check, Monitor, Smartphone, type LucideIcon } from "lucide-react"
|
|
|
|
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
|
|
import { Section } from "@/components/ui/section"
|
|
import { SectionHeading } from "@/components/ui/section-heading"
|
|
import { Typography } from "@/components/ui/typography"
|
|
|
|
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
|
|
export function HowItWorksDemo() {
|
|
const [activeKey, setActiveKey] = useState<DemoTab["key"]>("create")
|
|
|
|
const activeTab = DEMO_TABS.find((tab) => tab.key === activeKey)!
|
|
|
|
return (
|
|
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
|
|
<SectionHeading
|
|
align="center"
|
|
className="mb-20"
|
|
eyebrow="How it Works"
|
|
title="견적 생성부터 낙찰까지 한눈에 보기"
|
|
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
|
|
descriptionClassName="text-[17px] max-w-2xl"
|
|
/>
|
|
|
|
{/* 탭 스위치 */}
|
|
<div className="flex justify-center mb-16">
|
|
<div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1">
|
|
{DEMO_TABS.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveKey(tab.key)}
|
|
className={`px-5 py-2.5 rounded-control text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${
|
|
activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink"
|
|
}`}
|
|
>
|
|
<tab.tabIcon className="w-4 h-4" />
|
|
<span>{tab.tabLabel}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center min-h-[500px]">
|
|
{/* 좌: 단계 설명 */}
|
|
<div className="lg:col-span-5 space-y-6 text-center lg:text-left">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={`${activeTab.key}-text`}
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
transition={{ duration: 0.4 }}
|
|
className="space-y-6"
|
|
>
|
|
{/* 알약 배경 + 아이콘을 뺀 글자만의 라벨.
|
|
레퍼런스 라벨은 11px/600/자간 normal 에 배경도 아이콘도 없다. */}
|
|
<div className={`text-[11px] font-semibold ${activeTab.badge.className}`}>{activeTab.badge.label}</div>
|
|
<Typography variant="heading">{activeTab.heading}</Typography>
|
|
{activeTab.paragraphs.map((paragraph, idx) => (
|
|
<Typography key={idx} variant="body">
|
|
{paragraph}
|
|
</Typography>
|
|
))}
|
|
|
|
<div className="border-t border-line pt-5 mt-2 space-y-3">
|
|
{activeTab.checks.map((check, idx) => (
|
|
<div key={idx} className="flex items-center gap-2 text-xs font-bold text-ink-soft justify-center lg:justify-start">
|
|
<Check className={`w-4 h-4 ${activeTab.checkClassName}`} />
|
|
<span>{check}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* 우: 디바이스 목업 + GIF */}
|
|
<div className="lg:col-span-7 flex justify-center items-center">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={`${activeTab.key}-mockup`}
|
|
initial={{ opacity: 0, scale: 0.96, y: 15 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.96, y: -15 }}
|
|
transition={{ duration: 0.4 }}
|
|
className={`w-full ${activeTab.mockup === "phone" ? "flex justify-center" : ""}`}
|
|
>
|
|
{activeTab.mockup === "browser" ? (
|
|
<BrowserFrame url={activeTab.url!}>
|
|
<GifSlot tab={activeTab} />
|
|
</BrowserFrame>
|
|
) : (
|
|
<PhoneFrame>
|
|
<GifSlot tab={activeTab} />
|
|
</PhoneFrame>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
)
|
|
}
|
|
|
|
/** GIF 로드 실패 시(파일 미배치) 배치 안내 폴백을 보여주는 슬롯. */
|
|
function GifSlot({ tab }: { tab: DemoTab }) {
|
|
const [error, setError] = useState(false)
|
|
|
|
if (error) {
|
|
const phone = tab.mockup === "phone"
|
|
return (
|
|
<div className={phone ? "text-center p-6 space-y-4 w-full" : "text-center p-8 max-w-sm space-y-4"}>
|
|
<div className={`${tab.fallback.iconWrapClassName} flex items-center justify-center mx-auto animate-pulse`}>
|
|
<tab.fallback.icon className={phone ? "w-6 h-6" : "w-7 h-7"} />
|
|
</div>
|
|
<div>
|
|
<h4 className={`${phone ? "text-xs" : "text-sm"} font-bold text-ink break-keep`}>{tab.fallback.title}</h4>
|
|
<p className={`${phone ? "text-[10px] px-2" : "text-[11px]"} text-ink-muted font-semibold mt-1 leading-relaxed break-keep`}>
|
|
프로젝트 루트의{" "}
|
|
<code className={`px-1.5 py-0.5 rounded border ${tab.fallback.codeClassName}`}>public{tab.gif}</code> 경로에{" "}
|
|
{tab.fallback.hint}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// video(mp4)가 있으면 우선 재생 — 줌 스크린캐스트는 GIF보다 용량 1/5·화질 우위. 없으면 GIF.
|
|
if (tab.video) {
|
|
return (
|
|
<video
|
|
src={tab.video}
|
|
poster={tab.poster}
|
|
autoPlay
|
|
muted
|
|
loop
|
|
playsInline
|
|
onError={() => setError(true)}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<img
|
|
src={tab.gif}
|
|
alt={tab.gifAlt}
|
|
onError={() => setError(true)}
|
|
referrerPolicy="no-referrer"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)
|
|
}
|
|
|
|
type DemoTab = {
|
|
key: "create" | "negotiate" | "result"
|
|
tabIcon: LucideIcon
|
|
tabLabel: string
|
|
badge: { label: string; className: string }
|
|
checkClassName: string
|
|
heading: React.ReactNode
|
|
paragraphs: React.ReactNode[]
|
|
checks: string[]
|
|
mockup: "browser" | "phone"
|
|
url?: string
|
|
gif: string
|
|
video?: string
|
|
poster?: string
|
|
gifAlt: string
|
|
fallback: {
|
|
icon: LucideIcon
|
|
iconWrapClassName: string
|
|
codeClassName: string
|
|
title: string
|
|
hint: string
|
|
}
|
|
}
|
|
|
|
const DEMO_TABS: DemoTab[] = [
|
|
{
|
|
key: "create",
|
|
tabIcon: Monitor,
|
|
tabLabel: "1. 견적 · 기준 수립",
|
|
badge: { label: "구매 관리자 콘솔", className: "text-primary" },
|
|
checkClassName: "text-primary",
|
|
heading: (
|
|
<>
|
|
엑셀만 올리면 <br />
|
|
목표 단가가 잡힙니다
|
|
</>
|
|
),
|
|
paragraphs: [
|
|
<>
|
|
품목명과 시중 시장 가격을 입력하면, negotium이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
|
|
<b className="text-primary">시장 최저가를 기준으로 시작합니다.</b>
|
|
</>,
|
|
<>
|
|
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
|
|
<b className="text-primary">최적 목표 단가(Target Price)</b>를 자동으로 잡아줍니다.
|
|
</>,
|
|
],
|
|
checks: ["인터넷 최저가(LPS) 시세 상시 반영", "마진을 지키는 최적 목표 단가 자동 완성"],
|
|
mockup: "browser",
|
|
url: "https://console.negotium.ai/estimates/new",
|
|
gif: "/gifs/quote_generation.gif",
|
|
video: "/gifs/quote_generation.mp4",
|
|
poster: "/gifs/quote_generation.jpg",
|
|
gifAlt: "견적 생성 가이드라인 수립 시연",
|
|
fallback: {
|
|
icon: Monitor,
|
|
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-card text-primary",
|
|
codeClassName: "bg-white text-primary",
|
|
title: "견적 생성 시연 GIF 공간",
|
|
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
|
},
|
|
},
|
|
{
|
|
key: "negotiate",
|
|
tabIcon: Smartphone,
|
|
tabLabel: "2. 협력사별 1:1 협상",
|
|
badge: { label: "협력사 협상 포털", className: "text-positive" },
|
|
checkClassName: "text-positive",
|
|
heading: (
|
|
<>
|
|
공급사는 설치 없이 <br />
|
|
웹·모바일 어디서든 협상
|
|
</>
|
|
),
|
|
paragraphs: [
|
|
<>
|
|
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
|
|
</>,
|
|
<>
|
|
단순히 깎는 게 아닙니다. 에이전트는 준비된 협상 카드를 상황에 맞게 꺼내, 협력사가 받아들일 조건을
|
|
이끌어냅니다.
|
|
</>,
|
|
],
|
|
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
|
|
mockup: "phone",
|
|
gif: "/gifs/negotiation.gif",
|
|
gifAlt: "협력사 포털 모바일 협상 화면",
|
|
fallback: {
|
|
icon: Smartphone,
|
|
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
|
|
codeClassName: "bg-fill text-positive",
|
|
title: "협상 시연 GIF 공간",
|
|
hint: "실제 모바일 협상 시뮬레이션 GIF를 배치해 주세요.",
|
|
},
|
|
},
|
|
{
|
|
key: "result",
|
|
tabIcon: Monitor,
|
|
tabLabel: "3단계: AI 협상 결과 확인",
|
|
badge: { label: "구매 관리자 콘솔", className: "text-accent" },
|
|
checkClassName: "text-accent",
|
|
heading: (
|
|
<>
|
|
자동 분석 및 타결 보고서 <br />
|
|
대시보드에서 성과 확인
|
|
</>
|
|
),
|
|
paragraphs: [
|
|
<>
|
|
협상이 끝나면, 파트너사별 <b>최종 타결 단가와 절감액 리포트</b>를 대시보드에서 즉시 확인합니다.
|
|
</>,
|
|
<>
|
|
어떤 제안에 단가가 움직였는지 <b>협상 전 과정이 로그로 남고</b>, 공급사별 투찰가를 한눈에 비교해 계약을 검토할 수
|
|
있습니다.
|
|
</>,
|
|
],
|
|
checks: ["낙찰 결과·절감액 자동 집계", "누적 절감액·협상 통계 대시보드"],
|
|
mockup: "browser",
|
|
url: "https://console.negotium.ai/dashboard/reports",
|
|
gif: "/gifs/negotiation_result.gif",
|
|
video: "/gifs/negotiation_result.mp4",
|
|
poster: "/gifs/negotiation_result.jpg",
|
|
gifAlt: "협상 결과 분석 대시보드 시연",
|
|
fallback: {
|
|
icon: Monitor,
|
|
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-card text-accent",
|
|
codeClassName: "bg-white text-accent",
|
|
title: "협상 결과 확인 시연 GIF 공간",
|
|
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
|
},
|
|
},
|
|
]
|