[feat] landing: 랜딩 v2 — 다크 무대 히어로·인터랙티브 협상 데모·디자인 토큰 재정립

레퍼런스(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>
This commit is contained in:
Haewon Kam 2026-07-31 17:08:37 +09:00
parent 3c3f60c7d4
commit 6785c8a6f4
31 changed files with 1632 additions and 762 deletions

View File

@ -10,38 +10,101 @@
src: url('/fonts/PretendardVariable.woff2') format('woff2'); src: url('/fonts/PretendardVariable.woff2') format('woff2');
} }
/* 영문 디스플레이 서체 — 이탤릭 가변(400~900), latin 서브셋.
INFINITH 디자인 시스템과 같은 Playfair Display 를 쓴다. */
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/PlayfairDisplay-Italic.woff2') format('woff2');
}
/* /*
* 랜딩 디자인 토큰 (Toss 스타일 라이트 팔레트). * 랜딩 디자인 토큰.
* 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다. * 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다.
*
* 구조는 두 겹이다.
* - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다.
* - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지.
*/ */
@theme { @theme {
--font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; --font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 영문 디스플레이 — 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다
(Playfair 는 한글 글리프가 없어 Pretendard 로 폴백되며 톤이 깨진다). */
--font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif;
/* 브랜드 */ /* ── 브랜드 액센트 ──────────────────────────────────────────────
--color-primary: #476EFF; 여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)로
--color-primary-deep: #2F52E0; /* hover */ 되돌리려면 이 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */
--color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */ --color-primary: #0101F3; /* 일렉트릭 블루 — 라이트 배경 전용. 레퍼런스 실측값 */
/* 다크 무대용 액센트. #0101F3 을 #070E24 위에 얹으면 명암비가 2.0:1 로,
WCAG 최소(4.5:1)의 절반도 안 된다 — 둘 다 어두운 색이라 그렇다.
아래 값은 무대 배경 대비 약 6:1 이다. 무대 위 파란 글자는 반드시 이걸 쓴다. */
--color-primary-on-stage: #7C8CFF;
--color-primary-deep: #0101C4; /* hover */
--color-primary-soft: #EAEAFF; /* 연한 배경 — 에이전트 말풍선, 배지 */
/* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */ /* ── 상대편(공급사) ────────────────────────────────────────────
--color-ink: #191F28; 협상은 두 주체가 매 턴 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라
--color-ink-soft: #4E5968; /* 보조 본문 */ 구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다.
--color-ink-muted: #8B95A1; /* 캡션 */ NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */
--color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */ --color-counter: #0FFFD6; /* 무대 위 상대측 텍스트·강조 */
--color-counter-deep: #077A66; /* 라이트 배경 위 텍스트 (흰 바탕 대비 확보용) */
--color-counter-surface: #0A2F35; /* 무대 위 상대측 말풍선 바닥 */
/* ── 다크 무대 ────────────────────────────────────────────────── */
--color-stage: #070E24; /* 무대 바닥 (그라데이션의 중간값) */
--color-stage-deep: #03060F; /* 무대 가장자리 — 어두워지며 공간이 뒤로 물러난다 */
--color-stage-lift: #16205A; /* 무대 상단 광원 — 여기서 빛이 온다 */
--color-stage-raised: #101A3D; /* 무대 위 카드·상대측 말풍선 */
--color-stage-line: #1E2A52; /* 무대 위 테두리 */
--color-on-stage: #FFFFFF; /* 무대 위 1차 텍스트 */
/* 2·3차 텍스트는 중립 회색 대신 연보라 계열로 둔다. 네이비 위에 색상각이 다른
회색을 얹으면 탁해지는데, 같은 한색 가족이면 밝게 올려도 화면이 정돈된다.
#F5F3FF 는 ADO2 디자인 시스템의 연보라 패널 색이다. */
--color-on-stage-soft: #F5F3FF; /* 무대 위 2차 텍스트 — 2톤 헤드라인의 약한 쪽 */
--color-on-stage-muted: #C6C0E4; /* 무대 위 라벨·캡션 */
/* ── 라이트 본문 텍스트 ────────────────────────────────────────
먹색에 브랜드 색조를 섞어둔다. 순수 회색이면 페이지가 따로 논다. */
--color-ink: #0B1024;
--color-ink-soft: #454E6B; /* 보조 본문 */
--color-ink-muted: #838CAA; /* 캡션 */
--color-ink-faint: #AAB1C7; /* 플레이스홀더·각주 */
/* 면·선 */ /* 면·선 */
--color-surface: #F9FAFB; /* 회색 섹션 배경 */ --color-surface: #F7F8FC; /* 회색 섹션 배경 */
--color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */ --color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */
--color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */ --color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */
--color-fill-hover: #EAECEF; --color-fill-hover: #E6E9F2;
--color-line: #F2F4F6; /* 섹션 구분선 */ --color-line: #EEF0F6; /* 섹션 구분선 */
--color-line-strong: #E5E8EB; /* 카드 테두리 */ --color-line-strong: #E0E4EE; /* 카드 테두리 */
--color-positive: #0B8F57; /* 낙찰·성공 강조 */ --color-positive: #0B8F57; /* 낙찰·성공 강조 */
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */ --color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */ --color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
--color-accent-soft: #F4EFFF; /* 연한 보라 배경 */ --color-accent-soft: #F4EFFF; /* 연한 보라 배경 */
/* ── 모서리 ────────────────────────────────────────────────────
레퍼런스 실측은 3.5~7px 이다. 20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의
가장 강한 신호라, 여기서는 세 값으로만 고정한다.
card — 카드·패널·목업
control — 탭·배지·입력 등 작은 요소
full — 버튼·아바타 (Tailwind 기본 rounded-full 사용) */
--radius-card: 8px;
--radius-control: 4px;
}
/* 다크 무대 배경 그라데이션.
단색 배경은 아무리 입자에 원근을 줘도 평면으로 읽힌다. 위쪽에 광원을 두고
가장자리로 갈수록 어두워지게 하면 화면 자체에 안팎이 생겨 깊이가 성립한다. */
.stage-bg {
background:
radial-gradient(120% 78% at 50% 8%, var(--color-stage-lift) 0%, transparent 62%),
radial-gradient(100% 100% at 50% 42%, var(--color-stage) 0%, var(--color-stage-deep) 100%);
} }
html { html {

View File

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Bot, Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading" import { SectionHeading } from "@/components/ui/section-heading"
@ -47,7 +47,7 @@ export function Comparison() {
<motion.div {...fadeUp} className="mb-12"> <motion.div {...fadeUp} className="mb-12">
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="WHY NEGOTIUM" eyebrow="Before & After"
title="협상 방식을 바꾸면, 무엇이 달라질까요" title="협상 방식을 바꾸면, 무엇이 달라질까요"
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다." description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
descriptionClassName="mt-4 text-[17px] max-w-2xl" descriptionClassName="mt-4 text-[17px] max-w-2xl"
@ -56,16 +56,17 @@ export function Comparison() {
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */} {/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */}
<motion.div {...toggleFadeUp} className="flex justify-center mb-14"> <motion.div {...toggleFadeUp} className="flex justify-center mb-14">
<div className="p-1 bg-fill rounded-[14px] inline-flex gap-1"> {/* 액티브를 bg-white 로 두면 트랙(bg-fill #F0F2F8) 과 거의 같은 밝기라
멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */}
<div className="p-1.5 bg-fill rounded-full inline-flex gap-1.5">
{MODES.map((m) => ( {MODES.map((m) => (
<button <button
key={m.key} key={m.key}
onClick={() => selectMode(m.key)} onClick={() => selectMode(m.key)}
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${ className={`px-7 py-3 rounded-full text-[15px] font-semibold transition-colors cursor-pointer ${
mode === m.key ? "bg-white text-primary shadow-sm" : "text-ink-soft hover:text-ink" mode === m.key ? "bg-primary text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{m.key === "after" && <Bot className="w-4 h-4" />}
<span>{m.label}</span> <span>{m.label}</span>
</button> </button>
))} ))}
@ -97,13 +98,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind
return ( return (
<motion.div <motion.div
{...fadeUp} {...fadeUp}
className={`p-7 rounded-[28px] flex flex-col gap-5 transition-colors duration-500 ${ className={`p-7 rounded-card flex flex-col gap-5 transition-colors duration-500 ${
active ? "bg-primary-soft/60" : "bg-surface" active ? "bg-primary-soft/60" : "bg-surface"
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div <div
className={`w-11 h-11 rounded-2xl flex items-center justify-center transition-colors duration-500 ${ className={`w-11 h-11 rounded-card flex items-center justify-center transition-colors duration-500 ${
active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted" active ? "bg-primary/10 text-primary" : "bg-ink-muted/10 text-ink-muted"
}`} }`}
> >
@ -156,14 +157,14 @@ const ITEMS: CompareItem[] = [
{ {
label: "사람", label: "사람",
icon: UserRound, icon: UserRound,
before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.", before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.",
after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.", after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.",
}, },
{ {
label: "파트너 관계", label: "파트너 관계",
icon: Handshake, icon: Handshake,
before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.", before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.",
after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.", after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.",
}, },
{ {
label: "의사 결정", label: "의사 결정",

View File

@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react" import { Check, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input, Textarea } from "@/components/ui/input" import { Input, Textarea } from "@/components/ui/input"
@ -8,7 +8,6 @@ import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading" import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion" import { EASE_OUT_EXPO } from "@/lib/motion"
import type { LucideIcon } from "lucide-react"
const EMPTY_FORM = { const EMPTY_FORM = {
companyName: '', companyName: '',
@ -41,22 +40,13 @@ export function Contact() {
const submitting = status === 'submitting' const submitting = status === 'submitting'
return ( return (
<Section <Section id="contact-section" bordered>
id="contact-section"
bordered
decor={
<>
<div className="absolute top-[20%] right-[10%] w-72 h-72 bg-primary/5 blur-[100px] rounded-full pointer-events-none" />
<div className="absolute bottom-[10%] left-[5%] w-96 h-96 bg-primary/3 blur-[120px] rounded-full pointer-events-none" />
</>
}
>
<SectionHeading <SectionHeading
align="center" align="center"
className="mb-16" className="mb-16"
eyebrow="GET IN TOUCH" eyebrow="Get in Touch"
title="도입 문의 및 컨택하기" title="도입 문의"
description="사내 ERP 연동부터 우리 기업에 맞춘 흥정 시나리오 구성까지, negotium의 구매 혁신 컨설턴트가 상세히 안내해 드립니다." description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다."
descriptionClassName="max-w-xl" descriptionClassName="max-w-xl"
/> />
@ -70,10 +60,10 @@ export function Contact() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }} transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
className="space-y-6" className="space-y-9"
> >
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
<FormField label="회사명" icon={Building} required> <FormField label="회사명" required>
<Input <Input
type="text" type="text"
name="companyName" name="companyName"
@ -98,8 +88,8 @@ export function Contact() {
</FormField> </FormField>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
<FormField label="이메일 주소" icon={Mail} required> <FormField label="이메일 주소" required>
<Input <Input
type="email" type="email"
name="email" name="email"
@ -111,7 +101,7 @@ export function Contact() {
/> />
</FormField> </FormField>
<FormField label="연락처" icon={Phone} required> <FormField label="연락처" required>
<Input <Input
type="tel" type="tel"
name="phone" name="phone"
@ -128,7 +118,7 @@ export function Contact() {
<Textarea <Textarea
name="message" name="message"
rows={4} rows={4}
placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다." placeholder="협상 자동화를 검토 중인 품목이나 현재 겪는 어려움을 적어주세요."
value={formData.message} value={formData.message}
onChange={handleChange} onChange={handleChange}
disabled={submitting} disabled={submitting}
@ -136,22 +126,18 @@ export function Contact() {
</FormField> </FormField>
<Typography variant="caption" className="font-medium"> <Typography variant="caption" className="font-medium">
* 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게 입력하신 정보는 문의 응답과 도입 검토 목적으로만 사용됩니다.
보호됩니다.
</Typography> </Typography>
<div className="pt-2"> <div className="pt-2">
<Button type="submit" disabled={submitting} className="w-full shadow-sm hover:shadow-md"> <Button type="submit" disabled={submitting} size="lg" className="w-full">
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
<span>신청서 전송 중...</span> <span>신청서 전송 중...</span>
</> </>
) : ( ) : (
<> <span>상담 신청</span>
<Send className="w-5 h-5" />
<span>도입 문의 및 상담 신청하기</span>
</>
)} )}
</Button> </Button>
</div> </div>
@ -162,15 +148,14 @@ export function Contact() {
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }} transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
className="text-center py-16 px-6 bg-primary/5 rounded-[32px] text-ink break-keep" className="text-center py-20 px-6 border border-line-strong rounded-card text-ink break-keep"
> >
<div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle2 className="w-8 h-8" /> <Check className="w-6 h-6" strokeWidth={2.5} />
</div> </div>
<h3 className="text-2xl font-extrabold text-ink mb-3">도입 문의 신청이 접수되었습니다</h3> <h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
<p className="text-ink-soft font-semibold text-[15px] leading-relaxed max-w-md mx-auto mb-8"> <p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로 {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
연락을 드리겠습니다.
</p> </p>
<Button <Button
type="button" type="button"
@ -190,22 +175,14 @@ export function Contact() {
) )
} }
function FormField({ /* 라벨 옆 작은 아이콘은 정보를 더하지 않는다 — "회사명" 옆의 빌딩 아이콘이 알려주는 건
label, 이미 글자가 말한 것뿐이고, 필드마다 반복되면 폼이 산만해진다. 글자만 남긴다. */
icon: Icon, function FormField({ label, required = false, children }: { label: string; required?: boolean; children: React.ReactNode }) {
required = false,
children,
}: {
label: string
icon?: LucideIcon
required?: boolean
children: React.ReactNode
}) {
return ( return (
<div className="space-y-2"> <div className="space-y-2.5">
<label className="text-xs font-bold text-ink-soft flex items-center gap-1.5"> <label className="block text-[14px] font-medium text-ink">
{Icon && <Icon className="w-3.5 h-3.5 text-ink-muted" />} {label}
{label} {required && <span className="text-primary">*</span>} {required && <span className="text-primary ml-1" aria-hidden>*</span>}
</label> </label>
{children} {children}
</div> </div>

View File

@ -15,7 +15,7 @@ export function CoreValues() {
<motion.div {...fadeUp} className="mb-24"> <motion.div {...fadeUp} className="mb-24">
<SectionHeading <SectionHeading
align="center" align="center"
eyebrow="BEYOND EXPECTED VALUE" eyebrow="Beyond Price"
title="가격 그 이상의 이점" title="가격 그 이상의 이점"
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다." description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
descriptionClassName="mt-4 text-[17px]" descriptionClassName="mt-4 text-[17px]"
@ -37,7 +37,7 @@ function ValueStrip({ value, delay }: { value: Value; delay: number }) {
const fadeUp = useFadeUp(delay) const fadeUp = useFadeUp(delay)
return ( return (
<motion.div {...fadeUp} className="bg-surface p-8 rounded-[32px] flex items-start gap-5 transition-all hover:translate-y-[-4px]"> <motion.div {...fadeUp} className="bg-surface p-8 rounded-card flex items-start gap-5 transition-all hover:translate-y-[-4px]">
<div className="w-10 h-10 rounded-full bg-primary/5 flex items-center justify-center text-primary shrink-0 mt-1"> <div className="w-10 h-10 rounded-full bg-primary/5 flex items-center justify-center text-primary shrink-0 mt-1">
<value.icon className="w-5 h-5" /> <value.icon className="w-5 h-5" />
</div> </div>
@ -56,7 +56,7 @@ const VALUES: Value[] = [
icon: Users, icon: Users,
title: "파트너사 관계 수호", title: "파트너사 관계 수호",
description: description:
"단가를 깎는 악역과 감정 소모는 봇이 맡습니다. 담당자는 협력사와의 신뢰와 동반 성장, 큰 틀의 파트너십에만 집중하세요.", "단가 조율은 에이전트가 정해진 기준으로 반복합니다. 담당자는 협력사와의 신뢰와 동반 성장에 집중할 수 있습니다.",
}, },
{ {
icon: Lock, icon: Lock,
@ -68,7 +68,7 @@ const VALUES: Value[] = [
icon: Briefcase, icon: Briefcase,
title: "핵심 전략에만 집중", title: "핵심 전략에만 집중",
description: description:
"이메일·메신저로 반복되던 흥정 수작업은 봇이 전담합니다. 인력은 공급망 위기 대처, 우량 공급처 발굴 같은 전략 업무와 대형 거래에 투입하세요.", "이메일·메신저로 오가던 단가 협의는 에이전트가 전담합니다. 인력은 공급망 위기 대응과 신규 공급처 발굴에 투입하세요.",
}, },
{ {
icon: Clock, icon: Clock,

View File

@ -55,7 +55,7 @@ function FaqItem({
const fadeUp = useFadeUp(delay) const fadeUp = useFadeUp(delay)
return ( return (
<motion.div {...fadeUp} className="bg-surface rounded-[28px] overflow-hidden"> <motion.div {...fadeUp} className="bg-surface rounded-card overflow-hidden">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
@ -96,14 +96,14 @@ function FaqItem({
const FAQS: FaqEntry[] = [ const FAQS: FaqEntry[] = [
{ {
question: "공급사가 봇과의 협상을 싫어하지 않을까요?", question: "협력사가 에이전트와의 협상을 꺼리지 않을까요?",
answer: answer:
"오히려 반대입니다. 이 거래들 대부분은 지금껏 협상 테이블에 오르지도 못하던 건입니다. 봇은 24시간 원하는 시간에, 압박 없이, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%에 이릅니다.", "이 건들 대부분은 지금껏 협상 테이블에 오르지도 못하던 거래입니다. 에이전트는 협력사가 편한 시간에, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%로 보고됩니다.",
}, },
{ {
question: "봇이 우리 기준을 벗어나 합의해 버리면요?", question: "에이전트가 우리 기준을 벗어나 합의해 버리면요?",
answer: answer:
"그럴 수 없습니다. 봇은 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.", "그럴 수 없습니다. 에이전트는 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
}, },
{ {
question: "기존 시스템과 연동되나요?", question: "기존 시스템과 연동되나요?",

View File

@ -2,56 +2,45 @@ import { motion } from "motion/react"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { useFadeUp } from "@/lib/motion"
/** 최종 CTA — 다크 몰입 섹션. */ /**
* 최종 CTA — 히어로와 같은 다크 무대로 페이지를 닫는다.
*
* 이 섹션은 원래 디자인 시스템을 우회하고 있었다: bg-ink(무대 색이 아님), font-extrabold,
* 알약 배지, blur(120px) 글로우 블롭, 다크 배경 위 라이트 토큰(text-ink-muted).
* 전부 토큰으로 돌렸다. 글로우 블롭은 AI 가 만든 히어로의 대표 장식이라 걷어냈다.
*/
export function FinalCTA() { export function FinalCTA() {
const fadeUp = useFadeUp()
return ( return (
<section className="relative bg-ink text-white py-36 md:py-48 overflow-hidden"> <section className="stage-bg text-on-stage py-24 md:py-32">
{/* 다크 섹션 중앙의 은은한 파란 글로우 */} {/* max-w-3xl 이면 "조용히 새고 있을지 / 모릅니다" 로 끊겨 마지막 줄이 고아가 된다. */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-primary/15 blur-[120px] pointer-events-none" /> <div className="max-w-4xl mx-auto px-6 text-center">
<motion.div {...fadeUp}>
<Typography variant="eyebrow" className="block mb-5 text-primary-on-stage">
Start Today
</Typography>
<div className="max-w-3xl mx-auto px-6 text-center relative z-10"> <Typography variant="stageDisplay" as="h2" className="text-[32px] sm:text-[44px] md:text-[56px]">
<motion.div <span className="text-white">지금도 구매 예산은</span>
initial={{ opacity: 0, scale: 0.95 }} <br />
whileInView={{ opacity: 1, scale: 1 }} <span className="text-on-stage-soft/60">조용히 새고 있을지 모릅니다</span>
viewport={{ once: true }} </Typography>
className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-xs font-bold text-primary mb-8"
>
<span>START RECLAIMING YOUR BUDGET TODAY</span>
</motion.div>
<motion.h2 <p className="mt-8 text-[16px] sm:text-[17px] text-on-stage-soft/75 max-w-xl mx-auto leading-[1.6] break-keep">
initial={{ opacity: 0, y: 24 }} 협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. negotium 에이전트가 정해진 기준 안에서 그 건들을 대신
whileInView={{ opacity: 1, y: 0 }} 조율합니다.
viewport={{ once: true }} </p>
transition={{ duration: 0.8, delay: 0.1 }}
className="text-3xl sm:text-4xl md:text-[54px] font-extrabold tracking-tighter mb-8 text-white leading-[1.2] break-keep"
>
지금도 구매 예산은 <br className="sm:hidden" />
조용히 새고 있을지 모릅니다
</motion.h2>
<motion.p <div className="mt-12">
initial={{ opacity: 0, y: 24 }} <Button href="#contact-section" variant="stage" size="xl" className="group">
whileInView={{ opacity: 1, y: 0 }} <span>도입 상담 신청</span>
viewport={{ once: true }} <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
transition={{ duration: 0.8, delay: 0.2 }} </Button>
className="text-[17px] text-ink-muted max-w-xl mx-auto mb-14 leading-relaxed font-semibold break-keep" </div>
>
수작업으로 진행되어 수많은 단가 타협 기회를 흘려보내던 파트너사 흥정, 규칙 준수에 압도적으로 특화된 negotium 봇에
위임하고 재무 마진을 안전히 지키십시오.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3 }}
>
<Button href="#contact-section" size="xl" className="gap-2.5 hover:-translate-y-px">
<span>도입 및 계약 상담 신청하기</span>
<ArrowRight className="w-5 h-5" />
</Button>
</motion.div> </motion.div>
</div> </div>
</section> </section>

View File

@ -8,7 +8,7 @@ export function Footer() {
<div className="md:col-span-8"> <div className="md:col-span-8">
<Logo className="mb-6" /> <Logo className="mb-6" />
<Typography variant="caption" className="max-w-sm mb-6 font-medium"> <Typography variant="caption" className="max-w-sm mb-6 font-medium">
negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다. negotium은 협상 에이전트가 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
</Typography> </Typography>
<p className="text-xs text-ink-faint font-semibold"> <p className="text-xs text-ink-faint font-semibold">
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved. © {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.

View File

@ -5,36 +5,78 @@ import { Button } from "@/components/ui/button"
import { Logo } from "@/components/ui/logo" import { Logo } from "@/components/ui/logo"
export function Header() { export function Header() {
const [scrolled, setScrolled] = useState(false) // 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다.
// compact — 조금이라도 스크롤하면 높이를 줄인다.
// onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다.
const [compact, setCompact] = useState(false)
const [onStage, setOnStage] = useState(true)
// 스크롤 시 헤더를 반투명 블러 배경으로 전환
useEffect(() => { useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 40) const handleScroll = () => setCompact(window.scrollY > 40)
window.addEventListener("scroll", handleScroll, { passive: true }) window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll() handleScroll()
return () => window.removeEventListener("scroll", handleScroll) return () => window.removeEventListener("scroll", handleScroll)
}, []) }, [])
/* "다크 히어로 위에 있는가"는 스크롤 수치가 아니라 교차 상태다.
scrollY 로 계산하면 하이드레이션 전에 스크롤 위치가 잡힌 경우(ScrollRestoration,
새로고침, 뒤로가기) 초기 계산이 0 에서 끝나고 이후 스크롤 이벤트가 없으면
영영 갱신되지 않는다 — 흰 배경 위에 흰 글자 버튼이 남는다.
IntersectionObserver 는 관찰 시작 즉시 올바른 값을 준다. */
useEffect(() => {
const stage = document.querySelector("[data-stage-hero]")
if (!stage) {
setOnStage(false) // 다크 히어로가 없는 페이지에서는 항상 라이트
return
}
// 헤더 높이(약 80px)만큼 위에서 미리 전환되도록 상단 마진을 음수로 준다.
const io = new IntersectionObserver(([entry]) => setOnStage(entry.isIntersecting), {
rootMargin: "-80px 0px 0px 0px",
threshold: 0,
})
io.observe(stage)
return () => io.disconnect()
}, [])
return ( return (
<header <header
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${ /* 배경은 "스크롤됐을 때"만 깔되, 무대 위에서는 흰색이 아니라 무대색으로 깐다.
scrolled ? "py-4 bg-white/80 backdrop-blur-xl border-b border-line" : "py-6 bg-transparent border-b border-transparent" 투명한 채로 두면 히어로 카피가 헤더 밑으로 지나가며 nav 글자와 겹쳐 읽힌다. */
className={`fixed top-0 left-0 right-0 z-40 safe-t safe-x transition-all duration-300 ${compact ? "py-4" : "py-6"} ${
!compact
? "bg-transparent border-b border-transparent"
: onStage
? "bg-stage/80 backdrop-blur-xl border-b border-stage-line"
: "bg-white/80 backdrop-blur-xl border-b border-line"
}`} }`}
> >
<div className="max-w-5xl mx-auto px-6 flex items-center justify-between"> <div className="max-w-5xl mx-auto px-6 flex items-center justify-between">
<a href="#"> <a href="#">
<Logo /> <Logo className={onStage ? "brightness-0 invert" : undefined} />
</a> </a>
<nav className="hidden md:flex items-center gap-8 text-[15px] font-semibold text-ink-soft"> <nav
className={`hidden md:flex items-center gap-8 text-[15px] font-semibold transition-colors ${
onStage ? "text-on-stage-soft" : "text-ink-soft"
}`}
>
{NAV_LINKS.map(({ href, label }) => ( {NAV_LINKS.map(({ href, label }) => (
<a key={href} href={href} className="hover:text-ink transition-colors"> <a
key={href}
href={href}
className={`transition-colors ${onStage ? "hover:text-on-stage" : "hover:text-ink"}`}
>
{label} {label}
</a> </a>
))} ))}
</nav> </nav>
<Button href="#contact-section" size="pill" className="gap-1.5"> <Button
href="#contact-section"
variant={onStage ? "stageGhost" : "primary"}
size={onStage ? "stageRound" : "pill"}
className="gap-1.5"
>
<span>상담 신청</span> <span>상담 신청</span>
<ArrowUpRight className="w-4 h-4" /> <ArrowUpRight className="w-4 h-4" />
</Button> </Button>

View File

@ -1,51 +0,0 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
/** 히어로 변형 — 실시간 협상 콘솔 시즐 영상을 전면에 세운 풀와이드 히어로. */
export function HeroConsole() {
return (
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
<div className="mesh-bg absolute top-[-260px] left-[-160px] opacity-40 pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 w-full relative z-10">
<motion.div
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, delay: 0.1 }}
className="rounded-[28px] overflow-hidden border border-line-strong shadow-[0_30px_80px_rgba(20,25,45,0.12)] bg-white"
>
<video
src="/gifs/hero_console.mp4"
poster="/gifs/hero_console.jpg"
autoPlay
muted
loop
playsInline
className="w-full h-auto block"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.35 }}
className="flex flex-col sm:flex-row justify-center gap-5 pt-10"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span>도입 문의 상담 받기</span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span>작동 방식 보기</span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,82 @@
import { useRef, useState } from "react"
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { CLUSTER_ANCHORS, DataFlowCanvas, type DataFlowPhase } from "@/components/ui/data-flow-canvas"
import { OrbitRing } from "@/components/ui/orbit-ring"
/**
* 다크 무대 히어로 — 배경 모션이 negotium 파이프라인 자체를 그린다.
*
* 캡션이 모션과 같이 움직이는 게 핵심이다. 입자가 예뻐서 넣은 게 아니라
* 지금 화면에서 무슨 일이 일어나는지 읽히게 하려고 넣었다.
*/
export function HeroDataFlow() {
const [phase, setPhase] = useState<DataFlowPhase>("scatter")
// 순환 링이 캔버스와 같은 시계로 돌도록 진행도를 공유한다.
const progressRef = useRef(0)
return (
<section
data-stage-hero
className="stage-bg relative min-h-screen text-on-stage flex flex-col overflow-hidden"
>
<DataFlowCanvas onPhaseChange={setPhase} progressRef={progressRef} className="absolute inset-0 w-full h-full" />
{/* 무대 바닥으로 떨어지는 암전 — 아래 라이트 섹션과의 경계를 부드럽게 */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
{/* 분류 단계에서만 뜨는 무리 라벨. 아이콘만으로는 "무엇이" 분류됐는지 절반만 전달된다.
좌표는 캔버스가 쓰는 값을 그대로 받아 써서 둘이 어긋날 일이 없다. */}
<div aria-hidden className="absolute inset-0 pointer-events-none z-10">
{CLUSTER_ANCHORS.map((anchor) => (
<span
key={anchor.key}
className={`absolute -translate-x-1/2 -translate-y-1/2 whitespace-nowrap text-[13px] font-semibold text-on-stage-soft/80 transition-opacity duration-500 ${
phase === "cluster" ? "opacity-100" : "opacity-0"
}`}
style={{ left: `${anchor.x * 100}%`, top: `${anchor.labelY * 100}%` }}
>
{anchor.label}
</span>
))}
</div>
{/* 카피는 상단 55%, 데이터 흐름은 하단 밴드. 둘이 겹치지 않게 층을 나눈다. */}
<div className="relative z-10 max-w-4xl mx-auto px-6 w-full text-center pt-[11vh]">
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 캔버스가 맡는다. */}
{/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다.
네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */}
<Typography variant="stageDisplay">
<span className="text-white">협력사 전원과 1:1로,</span>
<br />
<span className="text-on-stage-soft/60">동시에 협상합니다.</span>
</Typography>
<p className="mt-7 text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto">
품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에
최저 투찰가로 낙찰까지 판정합니다.
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-3 justify-center">
<Button href="#how-it-works" variant="stage" size="stageRound" className="group">
<span>직접 협상해 보기</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button href="#contact-section" variant="stageGhost" size="stageRound">
도입 문의
</Button>
</div>
</div>
{/* 순환 링 — 정원. 점선 궤도만 돌고 마디·글자는 고정이다. */}
<div className="relative z-10 mt-auto flex justify-center pb-6">
<OrbitRing phase={phase} progressRef={progressRef} className="w-[300px] md:w-[340px] h-auto" />
</div>
</section>
)
}

View File

@ -1,85 +0,0 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
/** 기본 히어로 — 파스텔 블롭 + 그리드 + 떠다니는 와이어프레임 도형. */
export function HeroGlassmorphic() {
return (
<section className="relative min-h-screen bg-surface text-ink flex flex-col justify-center pt-32 overflow-hidden">
{/* 흐릿한 파스텔 블롭 배경 */}
<div className="absolute top-[10%] right-[15%] w-[480px] h-[480px] bg-primary/10 rounded-full filter blur-[120px] pointer-events-none" />
<div className="absolute bottom-[10%] left-[10%] w-[450px] h-[450px] bg-accent/10 rounded-full filter blur-[120px] pointer-events-none" />
<div className="absolute top-[30%] left-[30%] w-[380px] h-[380px] bg-primary/10 rounded-full filter blur-[100px] pointer-events-none" />
{/* 저채도 그리드 패턴 오버레이 */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.01)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.01)_1px,transparent_1px)] bg-[size:40px_40px] pointer-events-none" />
{/* 떠다니는 와이어프레임 도형 — max-w-7xl 안에 가둠 */}
<div className="absolute inset-0 max-w-7xl mx-auto pointer-events-none z-0">
<motion.div
animate={{ y: [0, -18, 0], rotate: [12, 42, 12] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[15%] left-[8vw] lg:left-[10%] w-16 h-16 sm:w-28 sm:h-28 border-2 border-primary/40 bg-white/40 shadow-[0_8px_32px_rgba(49,130,246,0.05)] rounded-[28px] pointer-events-none z-0"
/>
<motion.div
animate={{ rotate: -360 }}
transition={{ duration: 80, repeat: Infinity, ease: "linear" }}
className="absolute bottom-[18%] left-[6vw] lg:left-[12%] w-28 h-28 sm:w-48 sm:h-48 border-2 border-dashed border-accent/30 rounded-full pointer-events-none z-0"
/>
<motion.div
animate={{ x: [0, 10, 0], y: [0, 10, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[30%] right-[8vw] lg:right-[12%] w-20 h-20 sm:w-36 sm:h-36 border-2 border-primary/20 rounded-[40px] pointer-events-none z-0"
>
<div className="w-full h-full bg-gradient-to-tr from-primary/5 to-transparent rounded-[38px]" />
</motion.div>
</div>
<div className="max-w-4xl mx-auto px-6 w-full py-24 relative z-10 flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.1, ease: EASE_OUT_EXPO }}
>
<Typography variant="display" className="mb-8">
구매 협상, 이제 <span className="text-primary">봇이 알아서</span> <br className="hidden sm:inline" />
흥정부터 낙찰까지 자동으로
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.2, ease: EASE_OUT_EXPO }}
>
<Typography variant="lead" className="md:text-[20px] max-w-2xl mb-12">
협상할수록 강화학습으로 흥정 전략을 알아서 다듬어갑니다. <br className="hidden sm:inline" />
감정 없이, 지치지 않고, 24시간 연중무휴 — 결렬 대신 성사로 이끕니다.
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.3, ease: EASE_OUT_EXPO }}
className="flex flex-col sm:flex-row gap-4 items-center justify-center w-full max-w-md z-20"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span>도입 문의 상담 받기</span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span>작동 방식 보기</span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
</section>
)
}

View File

@ -1,73 +0,0 @@
import { motion } from "motion/react"
import { ArrowDown, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
/** 대안 히어로 — 뉴모피즘 배경 + 실제 모바일 협상 화면(GIF) 폰 목업. */
export function HeroNeumorphic() {
return (
<section className="relative min-h-screen bg-surface-neu text-ink flex flex-col justify-center pt-32 pb-20 overflow-hidden">
{/* 우측 상단 메시 제거 — 영상 배경(#F2F4F7)과 섹션 배경을 완전 동일하게 유지해 경계선 없이 블렌딩 */}
<div className="max-w-5xl mx-auto px-6 w-full relative z-10 grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
{/* 좌: 카피 + CTA */}
<div className="lg:col-span-7 space-y-8 text-left">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.1 }}>
<Typography variant="display" className="text-4xl sm:text-5xl lg:text-[54px] leading-[1.15]">
구매 협상, <br />
이제 <span className="text-primary">봇이 알아서</span> <br />
흥정부터 낙찰까지 자동으로
</Typography>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.2 }}>
<Typography variant="lead" className="max-w-xl">
불필요한 실랑이와 감정 소모 없이, 예산 상한선 내에서 최적의 원가 절감을 달성하세요. 데이터 기반의 자율 흥정 봇이
24시간 실시간 대리 협상을 완료합니다.
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="flex flex-col sm:flex-row gap-5 pt-4"
>
<Button
href="#contact-section"
className="w-full sm:w-auto group shadow-[0_8px_24px_rgba(49,130,246,0.15)] hover:scale-[1.02] active:scale-[0.98]"
>
<span>도입 문의 상담 받기</span>
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
<Button href="#how-it-works" variant="glass" className="w-full sm:w-auto gap-1.5">
<span>작동 방식 보기</span>
<ArrowDown className="w-4 h-4 text-ink-soft" />
</Button>
</motion.div>
</div>
{/* 우: 모바일 협상 채팅 + 외부 설명 UI(제안가·협상카드·낙찰 하이라이트) 합성 영상 */}
<div className="lg:col-span-5 flex justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="w-full flex justify-center"
>
<video
src="/gifs/negotiation_annotated_3d.mp4?v=w3"
poster="/gifs/negotiation_annotated_3d.jpg?v=w3"
autoPlay
muted
loop
playsInline
className="w-full max-w-[440px] h-auto"
/>
</motion.div>
</div>
</div>
</section>
)
}

View File

@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Bot, Check, Monitor, Smartphone, Sparkles, type LucideIcon } from "lucide-react" import { Check, Monitor, Smartphone, type LucideIcon } from "lucide-react"
import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups" import { BrowserFrame, PhoneFrame } from "@/components/ui/device-mockups"
import { Section } from "@/components/ui/section" import { Section } from "@/components/ui/section"
@ -18,20 +18,20 @@ export function HowItWorksDemo() {
<SectionHeading <SectionHeading
align="center" align="center"
className="mb-20" className="mb-20"
eyebrow="SERVICE DEMONSTRATION" eyebrow="How it Works"
title="견적 생성부터 낙찰까지 한눈에 보기" title="견적 생성부터 낙찰까지 한눈에 보기"
description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요." description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
descriptionClassName="text-[17px] max-w-2xl" descriptionClassName="text-[17px] max-w-2xl"
/> />
{/* 탭 스위치 */} {/* 탭 스위치 */}
<div className="flex justify-center mb-16"> <div className="flex justify-center mb-16">
<div className="p-1 bg-fill rounded-[14px] inline-flex flex-wrap justify-center gap-1"> <div className="p-1 bg-fill rounded-control inline-flex flex-wrap justify-center gap-1">
{DEMO_TABS.map((tab) => ( {DEMO_TABS.map((tab) => (
<button <button
key={tab.key} key={tab.key}
onClick={() => setActiveKey(tab.key)} onClick={() => setActiveKey(tab.key)}
className={`px-5 py-2.5 rounded-[10px] text-xs sm:text-sm font-bold transition-all flex items-center gap-2 cursor-pointer ${ 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" activeKey === tab.key ? "bg-white text-primary" : "text-ink-soft hover:text-ink"
}`} }`}
> >
@ -54,10 +54,9 @@ export function HowItWorksDemo() {
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="space-y-6" className="space-y-6"
> >
<div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-bold ${activeTab.badge.className}`}> {/* 알약 배경 + 아이콘을 뺀 글자만의 라벨.
<activeTab.badge.icon className="w-3.5 h-3.5" /> 레퍼런스 라벨은 11px/600/자간 normal 에 배경도 아이콘도 없다. */}
{activeTab.badge.label} <div className={`text-[11px] font-semibold ${activeTab.badge.className}`}>{activeTab.badge.label}</div>
</div>
<Typography variant="heading">{activeTab.heading}</Typography> <Typography variant="heading">{activeTab.heading}</Typography>
{activeTab.paragraphs.map((paragraph, idx) => ( {activeTab.paragraphs.map((paragraph, idx) => (
<Typography key={idx} variant="body"> <Typography key={idx} variant="body">
@ -159,7 +158,7 @@ type DemoTab = {
key: "create" | "negotiate" | "result" key: "create" | "negotiate" | "result"
tabIcon: LucideIcon tabIcon: LucideIcon
tabLabel: string tabLabel: string
badge: { icon: LucideIcon; label: string; className: string } badge: { label: string; className: string }
checkClassName: string checkClassName: string
heading: React.ReactNode heading: React.ReactNode
paragraphs: React.ReactNode[] paragraphs: React.ReactNode[]
@ -183,19 +182,19 @@ const DEMO_TABS: DemoTab[] = [
{ {
key: "create", key: "create",
tabIcon: Monitor, tabIcon: Monitor,
tabLabel: "1단계: AI 견적 & 가이드 수립", tabLabel: "1. 견적 · 기준 수립",
badge: { icon: Sparkles, label: "구매 관리자 콘솔 (웹)", className: "bg-primary-soft text-primary" }, badge: { label: "구매 관리자 콘솔", className: "text-primary" },
checkClassName: "text-primary", checkClassName: "text-primary",
heading: ( heading: (
<> <>
엑셀 등록만으로 끝나는 <br /> 엑셀만 올리면 <br />
초정밀 가이드라인 자율 수립 목표 단가가 잡힙니다
</> </>
), ),
paragraphs: [ paragraphs: [
<> <>
품목명과 시중 시장 가격을 입력하면, negotium이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "} 품목명과 시중 시장 가격을 입력하면, negotium이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
<b className="text-primary">시장 바닥값을 쥐고 시작합니다.</b> <b className="text-primary">시장 최저가를 기준으로 시작합니다.</b>
</>, </>,
<> <>
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "} 시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
@ -211,7 +210,7 @@ const DEMO_TABS: DemoTab[] = [
gifAlt: "견적 생성 가이드라인 수립 시연", gifAlt: "견적 생성 가이드라인 수립 시연",
fallback: { fallback: {
icon: Monitor, icon: Monitor,
iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-2xl text-primary", iconWrapClassName: "w-14 h-14 bg-primary-soft rounded-card text-primary",
codeClassName: "bg-white text-primary", codeClassName: "bg-white text-primary",
title: "견적 생성 시연 GIF 공간", title: "견적 생성 시연 GIF 공간",
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.", hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
@ -220,8 +219,8 @@ const DEMO_TABS: DemoTab[] = [
{ {
key: "negotiate", key: "negotiate",
tabIcon: Smartphone, tabIcon: Smartphone,
tabLabel: "2단계: AI 자동 밀당 협상", tabLabel: "2. 협력사별 1:1 협상",
badge: { icon: Bot, label: "협력사 협상 포털 (웹·모바일)", className: "bg-positive-soft border border-positive/15 text-positive" }, badge: { label: "협력사 협상 포털", className: "text-positive" },
checkClassName: "text-positive", checkClassName: "text-positive",
heading: ( heading: (
<> <>
@ -234,14 +233,14 @@ const DEMO_TABS: DemoTab[] = [
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다. 각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
</>, </>,
<> <>
단순 마진 깎기가 아닙니다. 봇은 준비된 협상 카드를 상황에 맞게 꺼내, 파트너사의 자발적인 마진 타협을 부드럽게 단순히 깎는 게 아닙니다. 에이전트는 준비된 협상 카드를 상황에 맞게 꺼내, 협력사가 받아들일 조건을
이끌어냅니다. 이끌어냅니다.
</>, </>,
], ],
checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"], checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
mockup: "phone", mockup: "phone",
gif: "/gifs/negotiation.gif", gif: "/gifs/negotiation.gif",
gifAlt: "AI 모바일 자동 협상 시연", gifAlt: "협력사 포털 모바일 협상 화면",
fallback: { fallback: {
icon: Smartphone, icon: Smartphone,
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive", iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
@ -254,7 +253,7 @@ const DEMO_TABS: DemoTab[] = [
key: "result", key: "result",
tabIcon: Monitor, tabIcon: Monitor,
tabLabel: "3단계: AI 협상 결과 확인", tabLabel: "3단계: AI 협상 결과 확인",
badge: { icon: Monitor, label: "구매 관리자 콘솔 (웹)", className: "bg-accent-soft border border-accent/15 text-accent" }, badge: { label: "구매 관리자 콘솔", className: "text-accent" },
checkClassName: "text-accent", checkClassName: "text-accent",
heading: ( heading: (
<> <>
@ -280,7 +279,7 @@ const DEMO_TABS: DemoTab[] = [
gifAlt: "협상 결과 분석 대시보드 시연", gifAlt: "협상 결과 분석 대시보드 시연",
fallback: { fallback: {
icon: Monitor, icon: Monitor,
iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-2xl text-accent", iconWrapClassName: "w-14 h-14 bg-accent-soft rounded-card text-accent",
codeClassName: "bg-white text-accent", codeClassName: "bg-white text-accent",
title: "협상 결과 확인 시연 GIF 공간", title: "협상 결과 확인 시연 GIF 공간",
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.", hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",

View File

@ -1,324 +0,0 @@
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 },
],
},
],
},
]

View File

@ -0,0 +1,371 @@
import { useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import { ArrowRight, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { EASE_OUT_EXPO } from "@/lib/motion"
import {
DEMO_ITEMS,
MAX_CARDS,
respondToOffer,
round10,
savings,
simulateBuyerRun,
won,
type DemoItem,
type Outcome,
type Role,
type Turn,
} from "@/lib/negotiation-sim"
type Step = "role" | "play"
/**
* 랜딩 인터랙티브 협상 데모.
*
* 스크롤로 재생만 되던 기존 콘솔을 대체한다. 구경이 아니라 사용자가 한쪽을 맡아
* 실제로 값을 제시하고 판정을 받는다 — 광고로 들어온 잠재고객이 제품을 만져보는 자리다.
*
* 판정 규칙은 lib/negotiation-sim 에 있고 이 파일은 화면만 맡는다.
*/
export function NegotiationDemo() {
const [step, setStep] = useState<Step>("role")
const [role, setRole] = useState<Role>("seller")
const [item, setItem] = useState<DemoItem>(DEMO_ITEMS[0])
const [turns, setTurns] = useState<Turn[]>([])
const [cardsUsed, setCardsUsed] = useState(0)
const [outcome, setOutcome] = useState<Outcome>("running")
const [offer, setOffer] = useState(DEMO_ITEMS[0].listPrice)
const [finalPrice, setFinalPrice] = useState(DEMO_ITEMS[0].listPrice)
const logRef = useRef<HTMLDivElement>(null)
const timers = useRef<number[]>([])
// 재생 중인 buyer 시나리오 타이머는 리셋·언마운트 때 반드시 걷어낸다.
const clearTimers = () => {
timers.current.forEach(clearTimeout)
timers.current = []
}
useEffect(() => clearTimers, [])
/** 역할 선택 화면으로 완전히 되돌린다. 같은 역할로만 다시 하면 나머지 절반은
영영 안 보게 되므로, 다시 해보기는 역할부터 고르게 하는 게 맞다. */
const restart = () => {
reset()
setStep("role")
}
const reset = (nextItem = item, nextRole = role) => {
clearTimers()
setTurns([])
setCardsUsed(0)
setOutcome("running")
setFinalPrice(nextItem.listPrice)
setOffer(nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow)
}
const pickRole = (r: Role) => {
setRole(r)
reset(item, r)
setStep("play")
}
const pickItem = (it: DemoItem) => {
setItem(it)
reset(it, role)
}
// 새 말풍선이 붙으면 기록을 바닥으로 붙인다.
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" })
}, [turns])
/** seller — 사용자가 공급사가 되어 값을 제시하고 에이전트가 응수한다. */
const submitOffer = () => {
if (outcome !== "running") return
const priced = round10(offer)
const mine: Turn = { id: turns.length, side: "counterpart", text: `${won(priced)}원까지는 맞춰드릴 수 있습니다.`, price: priced }
setTurns((prev) => [...prev, mine])
const res = respondToOffer(item, priced, cardsUsed)
const id = window.setTimeout(() => {
setTurns((prev) => [...prev, { id: prev.length, side: "agent", text: res.text, price: res.price }])
setFinalPrice(res.outcome === "award" ? priced : res.price)
setOutcome(res.outcome)
if (res.outcome === "running") {
setCardsUsed((c) => c + 1)
setOffer(res.price) // 에이전트가 부른 값에서 다시 시작
}
}, 620)
timers.current.push(id)
}
/** buyer — 목표가만 정하면 에이전트가 알아서 붙는 걸 지켜본다. */
const runBuyer = () => {
if (turns.length > 0) return
const target = round10(offer)
const run = simulateBuyerRun(item, target)
run.turns.forEach((turn, i) => {
const id = window.setTimeout(() => {
setTurns((prev) => [...prev, turn])
if (turn.price) setFinalPrice(turn.price)
if (i === run.turns.length - 1) setOutcome(run.outcome)
}, 500 + i * 900)
timers.current.push(id)
})
}
const done = outcome !== "running"
const { amount, rate } = savings(item, finalPrice)
const sliderMin = role === "seller" ? round10(item.anchor * 0.94) : round10(item.anchor * 0.94)
const sliderMax = role === "seller" ? item.listPrice : item.marketLow
return (
<section id="how-it-works" className="stage-bg text-on-stage py-24 md:py-32">
<div className="max-w-5xl mx-auto px-6">
<div className="text-center mb-14 md:mb-20">
<Typography variant="eyebrow" className="block mb-5 text-primary-on-stage">
Try it Yourself
</Typography>
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */}
<Typography variant="title" as="h2">
<span className="text-white">직접 협상해 보세요.</span>
<br />
<span className="text-on-stage-soft/60">에이전트는 정해진 기준을 벗어나지 않습니다.</span>
</Typography>
</div>
{/* AnimatePresence mode="wait" 를 쓰지 않는다. 나가는 애니메이션이 끝나야 다음
화면이 마운트돼서 클릭 후 0.8초가 빈다 — 역할 선택처럼 즉시 반응해야 하는
전환에는 맞지 않는 도구다. 나가는 연출 없이 바로 갈아끼우고 짧게 들어온다. */}
{step === "role" ? (
<motion.div
key="role"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<RoleCard
title="공급사로 해보기"
lead="직접 단가를 제시하면 에이전트가 응수합니다."
detail="협상 카드 3장 안에서 단가가 어디까지 조율되는지 확인하실 수 있습니다."
onClick={() => pickRole("seller")}
/>
<RoleCard
title="구매 담당자로 해보기"
lead="목표가만 정하면 에이전트가 협력사와 조율합니다."
detail="기준을 벗어난 목표가를 설정했을 때의 처리 방식도 함께 확인하실 수 있습니다."
onClick={() => pickRole("buyer")}
/>
</motion.div>
) : (
<motion.div
key="play"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12"
>
{/* 좌 — 품목·컨트롤·현재가 */}
<div className="lg:col-span-5 space-y-8">
<div className="flex flex-wrap gap-2">
{DEMO_ITEMS.map((it) => (
<button
key={it.id}
type="button"
onClick={() => pickItem(it)}
className={`px-4 py-2 rounded-full text-[13px] font-semibold transition-colors ${
it.id === item.id
? "bg-white text-stage"
: "bg-white/5 text-on-stage-soft/75 hover:bg-white/10 hover:text-white"
}`}
>
{it.name}
</button>
))}
</div>
<div>
<div className="text-[13px] text-on-stage-soft/55 mb-2">{item.spec}</div>
<div className="flex items-baseline gap-2">
<span className="text-[24px] font-medium text-on-stage-soft/55">₩</span>
<span className="text-[52px] md:text-[64px] font-extrabold tracking-[-0.045em] leading-none tabular-nums">
{won(finalPrice)}
</span>
</div>
<div className="h-7 mt-2">
{amount > 0 && (
<span className="text-[15px] font-semibold text-counter">
▼ {won(amount)}원 · {rate.toFixed(1)}%
</span>
)}
</div>
</div>
{!done && (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<label htmlFor="nego-offer" className="text-[14px] font-medium text-on-stage-soft/85">
{role === "seller" ? "제시 단가" : "목표 단가"}
</label>
<span className="text-[18px] font-bold tabular-nums">{won(round10(offer))}원</span>
</div>
<input
id="nego-offer"
type="range"
min={sliderMin}
max={sliderMax}
step={10_000}
value={offer}
onChange={(e) => setOffer(Number(e.target.value))}
disabled={role === "buyer" && turns.length > 0}
className="w-full accent-primary disabled:opacity-40"
/>
<div className="flex justify-between text-[12px] text-on-stage-soft/45">
<span>{won(sliderMin)}</span>
<span>{won(sliderMax)}</span>
</div>
<Button
type="button"
variant="stage"
size="lg"
className="w-full"
onClick={role === "seller" ? submitOffer : runBuyer}
disabled={role === "buyer" && turns.length > 0}
>
{role === "seller" ? "이 값으로 제시하기" : "협상 시작"}
</Button>
</div>
)}
{role === "seller" && (
<div className="flex items-center gap-3">
<span className="text-[13px] text-on-stage-soft/55">협상 카드</span>
<div className="flex gap-1.5">
{Array.from({ length: MAX_CARDS }, (_, i) => (
<span
key={i}
className={`w-7 h-1.5 rounded-full ${i < MAX_CARDS - cardsUsed ? "bg-primary-on-stage" : "bg-white/15"}`}
/>
))}
</div>
</div>
)}
</div>
{/* 우 — 협상 기록 */}
<div className="lg:col-span-7">
<div
ref={logRef}
className="h-[360px] md:h-[440px] overflow-y-auto scrollbar-none flex flex-col gap-4 pr-1"
>
{turns.length === 0 && (
<div className="m-auto text-center text-[15px] text-on-stage-soft/45 max-w-xs break-keep">
{role === "seller"
? "단가를 정해 제시하면 에이전트가 근거와 함께 응수합니다."
: "목표가를 정하고 협상을 시작하면 턴마다 재생됩니다."}
</div>
)}
{turns.map((turn) => (
<motion.div
key={turn.id}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: EASE_OUT_EXPO }}
className={`flex flex-col ${turn.side === "agent" ? "items-start" : "items-end"}`}
>
<span
className={`text-[12px] font-semibold mb-1.5 ${
turn.side === "agent" ? "text-primary-on-stage" : "text-counter"
}`}
>
{turn.side === "agent" ? "협상 에이전트" : role === "seller" ? "나 (협력사)" : "협력사"}
</span>
<div
className={`max-w-[88%] rounded-card px-5 py-4 text-[15px] leading-[1.6] break-keep ${
turn.side === "agent"
? "bg-primary text-white"
: "bg-counter-surface text-counter border border-counter/20"
}`}
>
{turn.text}
</div>
</motion.div>
))}
</div>
{done && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
className="mt-6 border-t border-stage-line pt-6"
>
<Typography variant="cardTitle" className="text-white mb-1.5">
{outcome === "award"
? `낙찰 · ${won(amount)}원 절감`
: "개찰 — 낙찰자 미정으로 마감했습니다"}
</Typography>
<p className="text-[15px] text-on-stage-soft/70 leading-[1.6] break-keep mb-6">
{outcome === "award"
? `최초 제시가 ${won(item.listPrice)}원에서 ${rate.toFixed(1)}% 내려왔습니다. 실제 운영에서는 협력사 수만큼 이 협상이 동시에 진행됩니다.`
: "기준을 벗어나는 값은 억지로 맞추지 않고 담당자에게 넘깁니다. 결렬이 아니라 사람이 판단할 자리를 남기는 것입니다."}
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button href="#contact-section" variant="stage" size="lg" className="group">
<span>우리 품목으로 상담받기</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button type="button" variant="stageGhost" size="lg" onClick={restart}>
<RotateCcw className="w-4 h-4" />
<span>역할 바꿔서 다시</span>
</Button>
</div>
</motion.div>
)}
</div>
</motion.div>
)}
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.
</p>
</div>
</section>
)
}
function RoleCard({
title,
lead,
detail,
onClick,
}: {
title: string
lead: string
detail: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="group text-left rounded-card border border-stage-line bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/25 transition-colors p-8 md:p-10"
>
<Typography variant="heading" as="h3" className="text-white mb-3">
{title}
</Typography>
<p className="text-[16px] text-on-stage-soft/85 leading-[1.6] break-keep mb-2">{lead}</p>
<p className="text-[14px] text-on-stage-soft/55 leading-[1.6] break-keep">{detail}</p>
<span className="mt-6 inline-flex items-center gap-1.5 text-[16px] font-semibold text-primary-on-stage">
시작하기
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</span>
</button>
)
}

View File

@ -11,18 +11,18 @@ export function Reinforcement() {
const fadeUp = useFadeUp() const fadeUp = useFadeUp()
return ( return (
<Section id="reinforcement" bg="surface" bordered className="border-b border-line"> <Section id="reinforcement" bg="stage">
<motion.div {...fadeUp} className="mb-20"> <motion.div {...fadeUp} className="mb-20">
<SectionHeading <SectionHeading
className="text-center md:text-left" className="text-center md:text-left"
eyebrow="REINFORCEMENT LEARNING" tone="stage"
eyebrow="Reinforcement Learning"
title="협상할수록, 더 좋은 조건으로" title="협상할수록, 더 좋은 조건으로"
description={ description={
<> <>
잘 깎는 노하우는 담당자 머릿속에만 남고, 성과는 그날의 감정과 컨디션에 흔들립니다. negotium은 진행된 모든 거래 잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
데이터와 파트너 거절 피드백을 <span className="font-bold text-primary">강화학습 기술</span>에 투입합니다. 협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
무턱대고 가격을 후려쳐 상대방의 반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수 받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
있는 범위 내에서 최선의 마진율을 뽑아내도록 조율 페이스를 학습합니다.
</> </>
} }
descriptionClassName="mt-8 max-w-3xl" descriptionClassName="mt-8 max-w-3xl"
@ -44,15 +44,15 @@ function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) {
const fadeUp = useFadeUp(delay) const fadeUp = useFadeUp(delay)
return ( return (
<motion.div {...fadeUp} className="bg-white p-8 rounded-[28px] flex flex-col justify-between h-[240px]"> <motion.div {...fadeUp} className="bg-stage-raised border border-stage-line p-8 rounded-card flex flex-col justify-between h-[240px]">
<div className="w-12 h-12 rounded-2xl bg-primary/5 text-primary flex items-center justify-center"> <div className="w-11 h-11 rounded-control bg-primary/15 text-on-stage flex items-center justify-center">
<pillar.icon className="w-6 h-6" /> <pillar.icon className="w-5 h-5" />
</div> </div>
<div> <div>
<Typography variant="cardTitle" className="mb-2"> <Typography variant="cardTitle" className="mb-2 text-on-stage">
{pillar.title} {pillar.title}
</Typography> </Typography>
<Typography variant="caption" className="text-ink-soft"> <Typography variant="caption" className="text-on-stage-soft">
{pillar.description} {pillar.description}
</Typography> </Typography>
</div> </div>
@ -68,12 +68,12 @@ const PILLARS: Pillar[] = [
}, },
{ {
icon: RefreshCw, icon: RefreshCw,
title: "지속적인 전략 최적화", title: "쓸수록 올라가는 기준선",
description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.", description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.",
}, },
{ {
icon: GitMerge, icon: GitMerge,
title: "낙찰 성사율 극대화", title: "결렬 없는 합의",
description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.", description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.",
}, },
] ]

View File

@ -25,7 +25,7 @@ export function ROISimulator() {
<SectionHeading <SectionHeading
align="center" align="center"
className="mb-24" className="mb-24"
eyebrow="ROI SIMULATION" eyebrow="ROI Simulation"
title={ title={
<> <>
negotium 도입 시 <br /> negotium 도입 시 <br />
@ -65,7 +65,7 @@ export function ROISimulator() {
<label className="text-[16px] font-bold text-ink">관리 중인 총 협력사 수</label> <label className="text-[16px] font-bold text-ink">관리 중인 총 협력사 수</label>
<span className="text-lg font-black text-primary font-mono">{suppliers}개사</span> <span className="text-lg font-black text-primary font-mono">{suppliers}개사</span>
</div> </div>
<Typography variant="caption">AI 봇 "밀당"이 동시에 일대일로 자동 조율할 잠재 공급사 수량입니다.</Typography> <Typography variant="caption">에이전트가 동시에 1:1로 조율할 협력사 수입니다.</Typography>
<div className="pt-2"> <div className="pt-2">
<RangeSlider min={5} max={300} step={5} value={suppliers} onChange={setSuppliers} /> <RangeSlider min={5} max={300} step={5} value={suppliers} onChange={setSuppliers} />
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2"> <div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
@ -76,11 +76,11 @@ export function ROISimulator() {
</div> </div>
</div> </div>
<div className="flex gap-3 bg-surface p-5 rounded-2xl border border-line"> <div className="flex gap-3 bg-surface p-5 rounded-card border border-line">
<ShieldCheck className="w-5 h-5 text-positive flex-shrink-0 mt-0.5" /> <ShieldCheck className="w-5 h-5 text-positive flex-shrink-0 mt-0.5" />
<Typography variant="caption" className="text-ink-soft"> <Typography variant="caption" className="text-ink-soft">
본 시뮬레이션은 글로벌 최저가 스캔(LPS) 시장 표준 및 다수 B2B 자율 협상 데이터의 보수적인 평균 수치(3.1% ~ 4.2% 최저가 스캔(LPS) 시세와 B2B 협상 데이터의 보수적 평균(3.1~4.2% 절감)을 적용한 추정치입니다.
절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다. 실제 데이터로 검증해 보시려면 파일만 주시면 됩니다.
</Typography> </Typography>
</div> </div>
</div> </div>
@ -96,8 +96,7 @@ export function ROISimulator() {
{formatSavings(estimatedSavingsValue)} {formatSavings(estimatedSavingsValue)}
</div> </div>
<Typography variant="caption" className="text-ink-soft font-medium pt-1"> <Typography variant="caption" className="text-ink-soft font-medium pt-1">
사각지대에 방치되던 꼬리 거래(Tail Spend)의 상시 병렬 협상 및 인터넷 실시간 앵커가 시세 연동을 통해, 기존 수동 그동안 손이 못 가던 꼬리 거래(Tail Spend)까지 동시에 협상합니다. 수동으로는 놓치던 구간입니다.
흥정으로 놓치고 있던 원가 이탈을 완벽히 방어해냅니다.
</Typography> </Typography>
</div> </div>
@ -109,7 +108,7 @@ export function ROISimulator() {
</span> </span>
</div> </div>
<Typography variant="caption"> <Typography variant="caption">
메일 전송, 유선 흥정, 엑셀 취합 등에 낭비되던 소모적인 절차가 실시간 링크 전송과 AI 봇의 24시간 자율 대응으로 메일 전송, 유선 협의, 엑셀 취합에 쓰이던 시간이 링크 한 번과 에이전트 응대로
완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다. 완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다.
</Typography> </Typography>
</div> </div>

View File

@ -6,7 +6,9 @@ import { cn } from "@/lib/utils"
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다 // CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
// (SSG 특성상 하이드레이션 전에도 동작). // (SSG 특성상 하이드레이션 전에도 동작).
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 transition-all cursor-pointer disabled:opacity-50", /* transition-colors 로 좁힌다. transition-all 이면 헤더 CTA 가 무대↔라이트로 variant 를
통째로 바꿀 때 배경이 투명에서 차오르며 플리커로 읽힌다. */
"inline-flex items-center justify-center gap-2 transition-colors duration-200 cursor-pointer disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
@ -14,13 +16,21 @@ const buttonVariants = cva(
secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink", secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink",
// 밝은 배경 위 반투명 보조 버튼 (글라스 히어로) // 밝은 배경 위 반투명 보조 버튼 (글라스 히어로)
glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm", glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm",
// 다크 무대 위 1차 CTA
stage: "bg-primary hover:bg-primary-deep text-white",
// 다크 무대 위 2차 CTA — 흰 알약
stageGhost: "bg-transparent hover:bg-white text-white hover:text-stage border border-stage-line hover:border-white",
}, },
// radius 는 사이즈 무관 rounded-2xl(16px) 로 통일 — 알약형 금지 /* 레퍼런스(statworx) 실측: 10.4px / 600 / 알약(radius 39px) / 패딩 12·24 / 높이 33px.
작고 조밀한 버튼이 확신 있어 보인다 — 크고 굵은 버튼은 설득이 아니라 호소로 읽힌다.
한글 보정: 10.4px 는 판독이 어려워 12~13px 로 올리고 높이도 터치 타깃까지 확보한다.
(기존 주석의 "알약형 금지" 규칙은 레퍼런스 채택으로 폐기했다.) */
size: { size: {
pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl", pill: "px-5 py-2.5 text-[12px] font-semibold rounded-full",
md: "px-6 py-2.5 text-xs font-bold rounded-2xl", md: "px-6 py-3 text-[12px] font-semibold rounded-full",
lg: "px-8 py-4.5 text-base font-bold rounded-2xl", lg: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
xl: "px-10 py-5 text-base font-bold rounded-2xl", xl: "px-8 py-4 text-[14px] font-semibold rounded-full",
stageRound: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
}, },
}, },
defaultVariants: { variant: "primary", size: "lg" }, defaultVariants: { variant: "primary", size: "lg" },

View File

@ -0,0 +1,466 @@
import { useEffect, useRef } from "react"
/**
* 히어로 배경 데이터 플로우 모션.
*
* negotium 의 실제 파이프라인을 그린다 — 흩어져 있던 거래 품목·사용자·협력사가
* 분류되고, 시장 최저가·협상 로그와 합류해 협상 카드로 응축되고, 가격 궤적을 그리며
* 협상이 진행되고, 그 결과가 다시 학습으로 되돌아가 처음으로 돌아간다.
*
* 장식이 아니라 설명이다. 그래서 열린 웨이브가 아니라 닫힌 루프고,
* 앞 두 단계에서는 점이 아니라 실제 글리프(표 행·사람·건물)로 그린다 —
* "무엇이" 분류되는지가 안 보이면 의미가 전달되지 않는다.
* 엔진에 들어가는 순간부터는 글리프가 점으로 추상화된다.
*
* 성능 메모
* - 글리프는 오프스크린 캔버스 스프라이트를 drawImage 로 블리팅한다. 매 프레임
* path 를 그리면 디테일 하나 늘 때마다 비용이 붙는다.
* - 점 구간은 색상군마다 Path2D 하나로 묶어 fill 호출을 9회로 제한한다.
* - 화면 밖(IntersectionObserver)·탭 비활성(visibilitychange)이면 rAF 를 멈춘다.
*/
/** 개체 종류 — 글리프와 색이 곧 데이터 출처다. */
const T_ITEM = 0 // 거래 품목 — 표 행 글리프, 일렉트릭 블루 (우리 쪽 데이터)
const T_USER = 1 // 사용자 — 사람 글리프, 밝은 청백
const T_PARTNER = 2 // 협력사 — 건물 글리프, 민트 (상대편 색)
const T_MARKET = 3 // 시장 최저가·로그 — 글리프 없는 점, 저채도
/* app.css 의 토큰과 같은 값이어야 한다. 캔버스는 CSS 변수를 못 읽으니 여기서 맞춘다.
--color-primary #0101F3 · 사용자 청백 · --color-counter #0FFFD6 · --color-on-stage-muted */
const TYPE_RGB = ["1,1,243", "196,206,240", "15,255,214", "106,118,168"] as const
const TYPE_ALPHA = [0.85, 0.8, 0.8, 0.4] as const
/** 종류별 개수. 합이 전체 입자 수. 분류 단계의 격자 크기와 맞물린다. */
const COUNTS = [72, 48, 60, 60] as const
const PARTICLE_COUNT = COUNTS.reduce((a, b) => a + b, 0)
const LOOP_MS = 10_500
/** 루프 구간. [시작비율] — 오름차순, 0 으로 시작. 여기가 제품 서사의 강약이다. */
const PHASES = [
{ key: "scatter", at: 0.0 },
{ key: "cluster", at: 0.15 },
{ key: "stream", at: 0.34 },
{ key: "card", at: 0.52 },
{ key: "negotiate", at: 0.67 },
{ key: "learn", at: 0.86 },
] as const
/** 단계별 글리프 표현 강도. 1 = 아이콘, 0 = 점. 엔진에 들어가면서 추상화된다. */
const PHASE_DETAIL = [1, 1, 0.3, 0, 0, 0] as const
/* 단계별 격자 정렬 강도. 1 이면 표류를 끈다.
표는 정렬이 곧 의미다 — 8px 표류가 9px 행 간격을 넘으면 표가 노이즈가 된다.
카드 응축도 형태가 또렷해야 해서 절반쯤 잡아준다. */
const PHASE_ORDER = [0, 1, 0, 0.6, 0, 0] as const
export type DataFlowPhase = (typeof PHASES)[number]["key"]
/* 구도는 화면 하단 밴드에 앉힌다. 헤드라인·CTA 는 그 위에 서고, 데이터는 무대 바닥을
흐르는 그림. 아래 목표 좌표는 전부 밴드 기준 지역 좌표(0..1)로 쓰고 band() 로 옮긴다. */
const BAND_TOP = 0.50
const BAND_H = 0.20 /* 아래쪽은 순환 링(정원) 자리로 비워둔다 */
const band = (localY: number) => BAND_TOP + localY * BAND_H
/* 카메라 — 소실점과 원근 세기. draw() 와 링 좌표 계산이 같은 값을 써야 어긋나지 않는다. */
export const VANISH_X = 0.5
export const VANISH_Y = BAND_TOP + BAND_H * 0.42
/** 깊이 → 원근 배율. 1 이면 카메라 평면, 작을수록 소실점으로 모인다. */
export const perspective = (z: number) => 0.42 + 0.58 * z
/* 분류 단계에서 각 무리가 앉는 자리와 깊이.
무리마다 z 평면을 하나씩 준다 — 입자마다 z 를 따로 주면 격자가 3D 로 뒤틀려
표로 안 읽힌다. 무리 단위로 주면 격자는 온전한 채 그룹 사이에만 깊이가 생긴다. */
export const CLUSTER_ANCHORS = [
{ key: "item", x: 0.17, z: 0.9, labelY: 0.525, label: "거래 품목" },
{ key: "user", x: 0.45, z: 0.66, labelY: 0.525, label: "사용자" },
{ key: "partner", x: 0.76, z: 0.44, labelY: 0.525, label: "협력사" },
] as const
/* 격자 배치 — 종류별 [열, 열간격(x, 화면비), 행간격(y, 밴드지역비)].
간격은 글리프보다 확실히 커야 한다. 붙으면 표가 아니라 노이즈로 읽힌다. */
const GRID = [
{ cols: 9, dx: 0.0205, dy: 0.055 }, // 품목: 9열 × 8행 시트
{ cols: 8, dx: 0.023, dy: 0.088 }, // 사용자
{ cols: 10, dx: 0.025, dy: 0.088 }, // 협력사
] as const
/** 결정적 난수 — 새로고침마다 구도가 달라지면 브랜드 자산이 안 된다. */
function makeRng(seed: number) {
let s = seed >>> 0
return () => {
s = (s * 1664525 + 1013904223) >>> 0
return s / 4294967296
}
}
const smoothstep = (t: number) => t * t * (3 - 2 * t)
/* 전환 이징. smoothstep 은 시작·끝이 모두 느려 "흐물흐물" 하게 읽힌다.
expo-out 은 첫 프레임부터 크게 움직이고 길게 안착해서 시원한 인상을 준다
— 모션 툴 계열 사이트가 공통으로 쓰는 감각이다. */
const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -11 * t))
/* ── 글리프 스프라이트 ─────────────────────────────────────────────────────
CSS 픽셀 기준 크기. 실제 캔버스는 SS 배로 그려서 축소 블리팅한다(선명도). */
const SPRITE_SS = 3
const GLYPH_SIZE = [
[22, 7], // 품목 — 가로 셀
[11, 11], // 사용자 — 원형 노드
[10, 17], // 협력사 — 세로 기둥. 행 간격(약 23px)보다 작아야 개체로 읽힌다
] as const
function makeSprite(type: number, rgb: string): HTMLCanvasElement {
const [w, h] = GLYPH_SIZE[type]
const c = document.createElement("canvas")
c.width = w * SPRITE_SS
c.height = h * SPRITE_SS
const g = c.getContext("2d")!
g.scale(SPRITE_SS, SPRITE_SS)
g.fillStyle = `rgb(${rgb})`
/* 형태는 구조만 말하고, 이름은 라벨이 맡는다.
창문 뚫린 건물·머리+어깨 사람 같은 문자 그대로의 픽토그램은 라벨과 의미가 중복되고
클립아트 어휘라 값싸 보인다. 가로 셀 / 원형 노드 / 세로 기둥 세 형태만으로도
세 무리는 충분히 구분되고, 훨씬 정돈돼 보인다. */
if (type === T_ITEM) {
g.fillRect(0, 0, w, h)
} else if (type === T_USER) {
g.beginPath()
g.arc(w / 2, h / 2, w / 2, 0, Math.PI * 2)
g.fill()
} else {
g.fillRect(0, 0, w, h)
}
return c
}
type Particle = {
type: number
/** 단계별 목표 좌표 [x, y, z] (0..1 정규화). z 는 1 이 카메라 쪽, 0 이 안쪽. */
targets: [number, number, number][]
/** 글리프 비율 변주 — 품목은 너비, 협력사는 높이가 개체마다 다르다(데이터처럼 보이게) */
ratio: number
/** 점으로 그려질 때의 반지름 */
dot: number
/** 글리프 밝기 배수 — 표의 머리행을 밝혀 "표"로 읽히게 한다 */
emphasis: number
/** 개체별 표류 위상 — 정지 구간에서도 죽어 보이지 않게 */
drift: number
speed: number
}
function buildParticles(): Particle[] {
const rnd = makeRng(20260731)
const blob = () => (rnd() + rnd() + rnd()) / 3 - 0.5
const out: Particle[] = []
// 종류를 인덱스 구간으로 고정 배정해 개수를 정확히 맞춘다(격자가 딱 떨어져야 표로 읽힌다).
const types: number[] = []
COUNTS.forEach((n, type) => {
for (let i = 0; i < n; i++) types.push(type)
})
const seen = [0, 0, 0, 0]
for (let i = 0; i < PARTICLE_COUNT; i++) {
const type = types[i]
const nth = seen[type]++
const t = i / PARTICLE_COUNT
// 1) 산개 — 밴드 전역에 표류.
// 깊이 있는 field — 개체마다 z 가 흩어져 원근이 크게 벌어진다.
const scatter: [number, number, number] = [rnd(), band(rnd()), 0.12 + rnd() * 0.88]
// 2) 분류 — 종류별 격자. 시장가(T_MARKET)는 아직 합류 전이라 흩어진 채 남는다.
let cluster: [number, number, number]
let emphasis = 1
if (type === T_MARKET) {
cluster = [rnd(), band(rnd()), 0.1 + rnd() * 0.3] // 시장가는 아직 뒤에 머문다
} else {
const { cols, dx, dy } = GRID[type]
const rows = Math.ceil(COUNTS[type] / cols)
const col = nth % cols
const row = Math.floor(nth / cols)
cluster = [
CLUSTER_ANCHORS[type].x + (col - (cols - 1) / 2) * dx,
band(0.55 + (row - (rows - 1) / 2) * dy),
CLUSTER_ANCHORS[type].z, // 무리 단위 z — 격자는 온전하고 그룹 사이에만 깊이가 생긴다
]
// 시트 머리행은 밝게 — 균일한 격자에 위계가 하나 생기면 "표"로 확정된다.
if (type === T_ITEM && row === 0) emphasis = 1.5
}
// 3) 합류 — 하나의 흐름으로. 시장가와 협력사는 위아래에서 합류해 들어온다.
const sx = 0.16 + t * 0.34
const converge = 1 - (sx - 0.16) / 0.34 // 오른쪽으로 갈수록 좁아진다
const lane = type === T_MARKET ? -0.34 : type === T_PARTNER ? 0.3 : 0
// 합류하면서 카메라 쪽으로 딸려 나온다 — 흐름에 전진감이 생긴다.
const stream: [number, number, number] = [sx, band(0.5 + (lane + blob() * 0.5) * converge), 0.3 + (1 - converge) * 0.6]
// 4) 응축 — 협상 카드 한 장. 테두리에 더 많이 붙여 카드 형태를 읽히게 한다.
const cw = 0.055
const ch = 0.19
let card: [number, number, number]
if (rnd() < 0.62) {
const p = rnd() * 4
const u = rnd() - 0.5
if (p < 1) card = [0.5 + u * cw * 2, 0.5 - ch, 0.92]
else if (p < 2) card = [0.5 + u * cw * 2, 0.5 + ch, 0.92]
else if (p < 3) card = [0.5 - cw, 0.5 + u * ch * 2, 0.92]
else card = [0.5 + cw, 0.5 + u * ch * 2, 0.92]
} else {
card = [0.5 + (rnd() - 0.5) * cw * 1.8, 0.5 + (rnd() - 0.5) * ch * 1.8, 0.88]
}
card[1] = band(card[1])
// 5) 협상 — 계단형 하강 궤적. 6턴으로 끊어 실제 카드 소진을 흉내낸다.
const turn = Math.floor(t * 6)
// 턴이 진행될수록 뒤로 물러난다 — 시간이 흐른 느낌.
const negotiate: [number, number, number] = [0.2 + t * 0.66, band(0.12 + turn * 0.145 + blob() * 0.1), 0.95 - turn * 0.1]
// 6) 회귀 — 크게 감아 좌측으로 되돌아간다. 루프임을 눈으로 알리는 구간.
const a = Math.PI * (0.06 + t * 0.88)
// 깊은 곳에서 돌아 나와 다시 앞으로 — 루프가 공간을 한 바퀴 돈다.
const learn: [number, number, number] = [0.5 + Math.cos(a) * 0.47, band(0.72 - Math.sin(a) * 0.7), 0.2 + t * 0.7]
out.push({
type,
targets: [scatter, cluster, stream, card, negotiate, learn],
emphasis,
ratio: 0.62 + rnd() * 0.5, // 품목 너비·협력사 높이 변주 — 균일하면 데이터가 아니라 무늬가 된다
dot: type === T_MARKET ? 0.9 + rnd() * 0.6 : 1.2 + rnd() * 1.0,
drift: rnd() * Math.PI * 2,
speed: 0.5 + rnd() * 0.9,
})
}
return out
}
export function DataFlowCanvas({
className = "",
onPhaseChange,
progressRef,
}: {
className?: string
/** 현재 단계를 밖으로 알린다 — 분류 라벨이 모션과 같이 움직이게 하는 용도. */
onPhaseChange?: (phase: DataFlowPhase) => void
/** 루프 진행도(0..1)를 매 프레임 써 넣는다. state 가 아니라 ref 인 이유는
60fps 로 setState 를 부르면 페이지 전체가 매 프레임 리렌더되기 때문이다. */
progressRef?: { current: number }
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
// 콜백이 매 렌더 새 함수여도 effect 가 재실행되지 않도록 ref 로 잡아둔다.
const phaseCb = useRef(onPhaseChange)
phaseCb.current = onPhaseChange
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d", { alpha: true })
if (!ctx) return
const particles = buildParticles()
const sprites = [makeSprite(T_ITEM, TYPE_RGB[0]), makeSprite(T_USER, TYPE_RGB[1]), makeSprite(T_PARTNER, TYPE_RGB[2])]
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
let width = 0
let height = 0
let dpr = 1
let lastPhase = -1
const resize = () => {
const rect = canvas.getBoundingClientRect()
dpr = Math.min(window.devicePixelRatio || 1, 2)
width = rect.width
height = rect.height
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
/**
* 헤드라인·본문·CTA 가 놓이는 중앙은 입자를 눌러 가독성을 확보한다.
* 가리는 게 아니라 옅게 만드는 쪽 — 텍스트 뒤로 데이터가 흐르는 인상은 남긴다.
*/
const textSafe = (x: number, y: number) => {
const dx = Math.abs(x - 0.5) / 0.46
const dy = Math.abs(y - 0.34) / 0.25
const d = Math.sqrt(dx * dx + dy * dy)
return d < 1 ? smoothstep(d) : 1
}
/* 점 구간에서 입자별 알파를 주려면 fill() 을 입자 수만큼 불러야 해서 못 쓴다.
대신 감쇠를 3단계로 양자화해 Path2D 3개에 나눠 담는다. */
const BUCKET_ALPHA = [0.1, 0.45, 1]
const bucketOf = (safe: number) => (safe < 0.34 ? 0 : safe < 0.72 ? 1 : 2)
const draw = (progress: number, time: number) => {
if (progressRef) progressRef.current = progress
ctx.clearRect(0, 0, width, height)
let idx = PHASES.length - 1
for (let i = 0; i < PHASES.length; i++) {
const next = i + 1 < PHASES.length ? PHASES[i + 1].at : 1
if (progress >= PHASES[i].at && progress < next) {
idx = i
break
}
}
if (idx !== lastPhase) {
lastPhase = idx
phaseCb.current?.(PHASES[idx].key)
}
const start = PHASES[idx].at
const end = idx + 1 < PHASES.length ? PHASES[idx + 1].at : 1
const local = (progress - start) / (end - start)
/* 머무름 먼저, 이동 나중.
구간이 시작될 때 입자는 이미 그 단계의 모양이고, 캡션도 같은 단계를 말한다.
앞 절반 동안 형태를 읽을 시간을 주고, 남은 절반에 다음 모양으로 넘어간다. */
// 형태를 읽을 시간. 너무 길면 늘어지고, 너무 짧으면 라벨을 못 읽는다.
const HOLD = 0.42
const t = local < HOLD ? 0 : easeOutExpo((local - HOLD) / (1 - HOLD))
const nextIdx = (idx + 1) % PHASES.length
const detail = PHASE_DETAIL[idx] + (PHASE_DETAIL[nextIdx] - PHASE_DETAIL[idx]) * t
const order = PHASE_ORDER[idx] + (PHASE_ORDER[nextIdx] - PHASE_ORDER[idx]) * t
const wobbleAmp = reduced ? 0 : 0.008 * (1 - order)
/* 좌표를 한 번만 계산해 두 표현(글리프/점)이 같은 값을 쓰게 한다.
z 는 원근 투영으로 화면 좌표에 반영한다 — 멀수록 화면 중앙으로 모이고
작아지고 흐려진다. 이게 없으면 아무리 색을 써도 평면으로 읽힌다. */
const px = new Float32Array(PARTICLE_COUNT)
const py = new Float32Array(PARTICLE_COUNT)
const ps = new Float32Array(PARTICLE_COUNT)
const pk = new Float32Array(PARTICLE_COUNT) // 원근 배율 (1 = 카메라 평면)
const indices: number[] = []
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = particles[i]
const from = p.targets[idx]
const to = p.targets[nextIdx]
const wobble = Math.sin(time * 0.0004 * p.speed + p.drift) * wobbleAmp
const nx = from[0] + (to[0] - from[0]) * t + wobble
const ny = from[1] + (to[1] - from[1]) * t + wobble * 0.7
const nz = from[2] + (to[2] - from[2]) * t
const k = perspective(nz)
const sx = VANISH_X + (nx - VANISH_X) * k
const sy = VANISH_Y + (ny - VANISH_Y) * k
px[i] = sx * width
py[i] = sy * height
ps[i] = textSafe(sx, sy)
pk[i] = k
indices.push(i)
}
// 먼 것부터 그린다 — 가까운 개체가 위에 겹쳐야 깊이가 성립한다.
const sorted = indices.sort((a, b) => pk[a] - pk[b])
// 점 표현 — 글리프가 완전히 켜지지 않은 동안 항상 깔린다(교차 페이드).
if (detail < 0.97) {
for (let type = 0; type < 4; type++) {
const buckets = [new Path2D(), new Path2D(), new Path2D()]
for (const i of sorted) {
if (particles[i].type !== type) continue
const r = Math.max(0.35, particles[i].dot * pk[i])
const path = buckets[bucketOf(ps[i] * (0.45 + 0.55 * pk[i]))]
path.moveTo(px[i] + r, py[i])
path.arc(px[i], py[i], r, 0, Math.PI * 2)
}
// 시장가는 글리프가 없으므로 항상 제 밝기로 그린다.
const fade = type === T_MARKET ? 1 : 1 - detail
for (let b = 0; b < 3; b++) {
ctx.fillStyle = `rgba(${TYPE_RGB[type]},${(TYPE_ALPHA[type] * BUCKET_ALPHA[b] * fade).toFixed(3)})`
ctx.fill(buckets[b])
}
}
}
// 글리프 표현 — 스프라이트 블리팅.
if (detail > 0.03) {
for (const i of sorted) {
const type = particles[i].type
if (type === T_MARKET) continue
const [bw, bh] = GLYPH_SIZE[type]
const r = particles[i].ratio
// 품목은 너비가, 협력사는 높이가 개체마다 다르다 — 값이 있는 데이터처럼 보이게.
const gw = (type === T_ITEM ? bw * r : bw) * pk[i]
const gh = (type === T_PARTNER ? bh * r : bh) * pk[i]
ctx.globalAlpha =
Math.min(1, TYPE_ALPHA[type] * particles[i].emphasis) * detail * ps[i] * (0.4 + 0.6 * pk[i])
ctx.drawImage(sprites[type], px[i] - gw / 2, py[i] - gh / 2, gw, gh)
}
ctx.globalAlpha = 1
}
}
/* 정지 프레임으로 고정하는 두 경우.
1) prefers-reduced-motion — 카드 응축 순간을 보여준다.
2) ?flow=0.42 같은 쿼리 — 특정 프레임 고정. 디자인 리뷰·OG 이미지 촬영용이고,
백그라운드 탭에선 브라우저가 rAF 를 아예 안 돌려 스크린샷이 안 되기 때문에도 필요하다. */
const pinned = new URLSearchParams(window.location.search).get("flow")
const pinnedAt = pinned !== null ? Number(pinned) : null
const still = pinnedAt !== null && Number.isFinite(pinnedAt) ? pinnedAt : reduced ? PHASES[1].at : null
if (still !== null) {
draw(still, 0)
const onResize = () => {
resize()
draw(still, 0)
}
window.addEventListener("resize", onResize)
return () => window.removeEventListener("resize", onResize)
}
let raf = 0
let visible = true
const startedAt = performance.now()
const tick = (now: number) => {
draw(((now - startedAt) % LOOP_MS) / LOOP_MS, now)
raf = requestAnimationFrame(tick)
}
const play = () => {
if (!raf) raf = requestAnimationFrame(tick)
}
const pause = () => {
if (raf) cancelAnimationFrame(raf)
raf = 0
}
const io = new IntersectionObserver(
([entry]) => {
visible = entry.isIntersecting
if (visible && !document.hidden) play()
else pause()
},
{ threshold: 0 },
)
io.observe(canvas)
const onVisibility = () => {
if (document.hidden) pause()
else if (visible) play()
}
const onResize = () => resize()
document.addEventListener("visibilitychange", onVisibility)
window.addEventListener("resize", onResize)
play()
return () => {
pause()
io.disconnect()
document.removeEventListener("visibilitychange", onVisibility)
window.removeEventListener("resize", onResize)
}
}, [])
return <canvas ref={canvasRef} aria-hidden="true" className={className} />
}

View File

@ -3,13 +3,13 @@ import type * as React from "react"
/** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */ /** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) { function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
return ( return (
<div className="bg-white rounded-2xl border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl"> <div className="bg-white rounded-card border border-line-strong shadow-[0_12px_40px_rgba(0,0,0,0.06)] overflow-hidden w-full max-w-2xl">
<div className="bg-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong"> <div className="bg-fill px-4 py-3.5 flex items-center justify-between border-b border-line-strong">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#FF5F56] inline-block" /> <span className="w-3 h-3 rounded-full bg-[#FF5F56] inline-block" />
<span className="w-3 h-3 rounded-full bg-[#FFBD2E] inline-block" /> <span className="w-3 h-3 rounded-full bg-[#FFBD2E] inline-block" />
<span className="w-3 h-3 rounded-full bg-[#27C93F] inline-block" /> <span className="w-3 h-3 rounded-full bg-[#27C93F] inline-block" />
<span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-md border border-line-strong truncate max-w-[180px] sm:max-w-none"> <span className="text-[11px] text-ink-muted font-mono ml-3 font-semibold bg-white px-3 py-1 rounded-control border border-line-strong truncate max-w-[180px] sm:max-w-none">
{url} {url}
</span> </span>
</div> </div>
@ -22,9 +22,9 @@ function BrowserFrame({ url, children }: { url: string; children: React.ReactNod
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */ /** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
function PhoneFrame({ children }: { children: React.ReactNode }) { function PhoneFrame({ children }: { children: React.ReactNode }) {
return ( return (
<div className="relative bg-black rounded-[48px] p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col"> <div className="relative bg-black rounded-card p-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] border-4 border-line-strong w-full max-w-[290px] aspect-[9/18] overflow-hidden flex flex-col">
{/* 노치는 화면 '안쪽'에 작은 아일랜드로 — 베젤과 붙으면 검은 덩어리처럼 보인다 */} {/* 노치는 화면 '안쪽'에 작은 아일랜드로 — 베젤과 붙으면 검은 덩어리처럼 보인다 */}
<div className="bg-white rounded-[36px] flex-1 overflow-hidden relative flex flex-col justify-center items-center"> <div className="bg-white rounded-card flex-1 overflow-hidden relative flex flex-col justify-center items-center">
<div className="absolute top-1 left-1/2 -translate-x-1/2 bg-black/70 h-0.5 w-3.5 rounded-full z-20" /> <div className="absolute top-1 left-1/2 -translate-x-1/2 bg-black/70 h-0.5 w-3.5 rounded-full z-20" />
{children} {children}
</div> </div>

View File

@ -2,16 +2,31 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// 문의 폼 필드 공통 스타일 — 테두리 없는 회색 채움, 포커스 시 흰 배경 + 파란 링. /*
* 문의 폼 필드.
*
* 회색 채움 박스는 어느 템플릿에나 있다. 밑줄 필드로 바꾸면 같은 기능인데 훨씬
* 정돈돼 보인다 — 폼이 "입력 상자 더미"가 아니라 한 장의 문서처럼 읽힌다.
*
* 밑줄 폼의 알려진 위험은 입력란으로 안 보이는 것(어포던스 손실)인데,
* 라벨을 항상 위에 두고 밑줄을 2px 로 두껍게 잡아 보완했다.
* 두께를 항상 2px 로 고정해서 포커스 때 레이아웃이 흔들리지 않는다.
*
* 글자 16px 은 취향이 아니라 필수다 — iOS 사파리는 16px 미만 입력에 포커스하면
* 화면을 자동 확대해서, 광고로 들어온 모바일 사용자의 폼 이탈을 만든다.
*/
const fieldClass = const fieldClass =
"w-full px-5 py-3.5 rounded-2xl bg-surface hover:bg-fill focus:bg-white border-0 focus:ring-2 focus:ring-primary/20 text-sm font-semibold text-ink transition-all placeholder:text-ink-faint outline-none" "w-full bg-transparent border-0 border-b-2 border-line-strong rounded-none px-0 py-3 " +
"text-[16px] font-normal text-ink placeholder:text-ink-faint placeholder:font-normal " +
"outline-none transition-colors hover:border-ink-muted focus:border-primary " +
"disabled:opacity-50 disabled:cursor-not-allowed"
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) { function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(fieldClass, className)} {...props} /> return <input className={cn(fieldClass, className)} {...props} />
} }
function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) { function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(fieldClass, "resize-none", className)} {...props} /> return <textarea className={cn(fieldClass, "resize-none leading-[1.6]", className)} {...props} />
} }
export { Input, Textarea } export { Input, Textarea }

View File

@ -0,0 +1,152 @@
import { useEffect, useRef, type RefObject } from "react"
import type { DataFlowPhase } from "@/components/ui/data-flow-canvas"
/**
* 파이프라인 순환 링 — 정원.
*
* 앞서 3D 로 눕힌 타원을 썼는데, 깊이는 생기지만 정원의 완결감이 사라진다.
* negotium 의 메시지는 "닫힌 루프"라서 완결감 쪽이 맞다.
*
* 구조는 레퍼런스(infinith)와 같다 — 점선 원만 천천히 돌고, 마디와 글자는 고정이다.
* 글자까지 같이 돌면 읽을 수가 없다.
*/
const NODES: { key: DataFlowPhase; label: string }[] = [
{ key: "scatter", label: "Collect" },
{ key: "cluster", label: "Classify" },
{ key: "stream", label: "Benchmark" },
{ key: "card", label: "Anchor" },
{ key: "negotiate", label: "Negotiate" },
{ key: "learn", label: "Learn" },
]
/* viewBox 는 정사각이 아니다. 라벨이 궤도 바깥 좌우로 뻗기 때문에 가로 여백이 더 필요한데,
정사각으로 두면 "Benchmark" 같은 긴 라벨이 잘린다(SVG 는 viewBox 밖을 그냥 자른다).
가로만 넓히면 링 크기는 유지한 채 라벨 자리를 확보할 수 있다. */
const VB_W = 176
const VB_H = 150
const CX = VB_W / 2
const CY = VB_H / 2
const R = 46 // 궤도 반지름
const R_LABEL = 58 // 라벨은 궤도 바깥에
const rad = (ratio: number) => ratio * Math.PI * 2 - Math.PI / 2 // 12시에서 시계방향
const at = (ratio: number, radius: number) => ({
x: CX + Math.cos(rad(ratio)) * radius,
y: CY + Math.sin(rad(ratio)) * radius,
})
export function OrbitRing({
phase,
progressRef,
className = "",
}: {
phase: DataFlowPhase
/** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1 */
progressRef: RefObject<number>
className?: string
}) {
const activeIndex = Math.max(
0,
NODES.findIndex((n) => n.key === phase),
)
const headRef = useRef<SVGCircleElement>(null)
// 궤도를 도는 머리는 rAF 로 직접 갱신한다. 60fps 로 setState 를 부르면 페이지가 매 프레임 리렌더된다.
useEffect(() => {
let raf = 0
const tick = () => {
const p = progressRef.current ?? 0
const { x, y } = at(p, R)
if (headRef.current) {
headRef.current.setAttribute("cx", String(x))
headRef.current.setAttribute("cy", String(y))
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [progressRef])
return (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className={className} role="img" aria-label="협상 파이프라인 순환">
<defs>
<linearGradient id="orbitGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--color-primary-on-stage)" stopOpacity="0.9" />
<stop offset="55%" stopColor="var(--color-on-stage-soft)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--color-counter)" stopOpacity="0.55" />
</linearGradient>
</defs>
{/* 점선 궤도 — 이 레이어만 돈다 */}
<g className="animate-[spin_22s_linear_infinite] motion-reduce:animate-none" style={{ transformOrigin: `${CX}px ${CY}px` }}>
<circle cx={CX} cy={CY} r={R} fill="none" stroke="url(#orbitGrad)" strokeWidth="0.6" strokeDasharray="2.2 2.4" />
</g>
{/* 바깥 보조 호 — 정지. 원이 하나면 심심하고, 둘이면 공간이 생긴다. */}
<circle
cx={CX}
cy={CY}
r={R + 9}
fill="none"
stroke="var(--color-on-stage-soft)"
strokeOpacity="0.1"
strokeWidth="0.4"
/>
{/* 마디 + 라벨 — 고정 레이어 */}
{NODES.map((n, i) => {
const ratio = i / NODES.length
const p = at(ratio, R)
const l = at(ratio, R_LABEL)
const cos = Math.cos(rad(ratio))
const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"
const active = i === activeIndex
return (
<g key={n.key}>
<circle
cx={p.x}
cy={p.y}
r={active ? 2.6 : 1.4}
fill={active ? "var(--color-primary-on-stage)" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.45}
style={{ transition: "r 240ms ease-out, opacity 240ms ease-out" }}
/>
<text
x={l.x}
y={l.y + 1.6}
textAnchor={anchor}
fontSize="5"
fontWeight={active ? 700 : 500}
fill={active ? "#FFFFFF" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.5}
style={{ transition: "opacity 240ms ease-out" }}
>
{n.label}
</text>
</g>
)
})}
{/* 궤도를 도는 머리 */}
<circle ref={headRef} cx={CX} cy={CY - R} r="2" fill="#FFFFFF" opacity="0.95" />
{/* 가운데 */}
<text x={CX} y={CY - 1} textAnchor="middle" fontSize="7" fontWeight="700" fill="#FFFFFF">
{NODES[activeIndex].label}
</text>
<text
x={CX}
y={CY + 8.5}
textAnchor="middle"
fontSize="4.2"
fontWeight="500"
fill="var(--color-on-stage-soft)"
opacity="0.5"
>
{String(activeIndex + 1).padStart(2, "0")} / {String(NODES.length).padStart(2, "0")}
</text>
</svg>
)
}

View File

@ -17,7 +17,7 @@ function RangeSlider({ min, max, step, value, onChange }: RangeSliderProps) {
step={step} step={step}
value={value} value={value}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 rounded-lg appearance-none cursor-pointer accent-primary transition-all" className="w-full h-1.5 rounded-control appearance-none cursor-pointer accent-primary transition-all"
style={{ style={{
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`, background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`,
}} }}

View File

@ -3,25 +3,50 @@ import * as React from "react"
import { Typography } from "@/components/ui/typography" import { Typography } from "@/components/ui/typography"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/*
* 섹션 머리.
*
* eyebrow 는 선택이고, 기본적으로 쓰지 않는다. 작은 색 라벨을 섹션마다 반복하면
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
* 위계는 라벨이 아니라 헤드라인 크기(본문의 3.5~4배)와 2톤 대비로 만든다.
*/
type SectionHeadingProps = { type SectionHeadingProps = {
eyebrow: string eyebrow?: string
title: React.ReactNode title: React.ReactNode
description?: React.ReactNode description?: React.ReactNode
align?: "left" | "center" align?: "left" | "center"
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
tone?: "light" | "stage"
className?: string className?: string
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */ /** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
descriptionClassName?: string descriptionClassName?: string
} }
function SectionHeading({ eyebrow, title, description, align = "left", className, descriptionClassName }: SectionHeadingProps) { function SectionHeading({
eyebrow,
title,
description,
align = "left",
tone = "light",
className,
descriptionClassName,
}: SectionHeadingProps) {
const onStage = tone === "stage"
return ( return (
<div className={cn(align === "center" && "text-center", className)}> <div className={cn(align === "center" && "text-center", className)}>
<Typography variant="eyebrow" className="mb-3 block"> <Typography variant="eyebrow" className={cn("mb-3 block", onStage && "text-primary-on-stage")}>
{eyebrow} {eyebrow}
</Typography> </Typography>
<Typography variant="title">{title}</Typography> <Typography variant="title" className={cn(onStage && "text-on-stage")}>
{title}
</Typography>
{description && ( {description && (
<Typography variant="lead" className={cn("mt-5", align === "center" && "mx-auto", descriptionClassName)}> <Typography
variant="lead"
className={cn("mt-5", align === "center" && "mx-auto", onStage && "text-on-stage-soft", descriptionClassName)}
>
{description} {description}
</Typography> </Typography>
)} )}

View File

@ -6,6 +6,8 @@ const sectionBg = {
white: "bg-white", white: "bg-white",
surface: "bg-surface", surface: "bg-surface",
dark: "bg-ink text-white", dark: "bg-ink text-white",
/** 히어로와 같은 무대. 어두운 섹션은 이걸 쓴다 — 페이지 안에 어둠이 두 종류면 따로 논다. */
stage: "stage-bg text-on-stage",
} }
const sectionWidth = { const sectionWidth = {
@ -37,7 +39,8 @@ function Section({
}: SectionProps) { }: SectionProps) {
return ( return (
<section <section
className={cn("py-36 md:py-44", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)} /* 레퍼런스 섹션 패딩은 104~139px. 176px 은 과해서 "채울 내용이 없어" 보인다. */
className={cn("py-24 md:py-32", sectionBg[bg], bordered && "border-t border-line", decor && "relative overflow-hidden", className)}
{...props} {...props}
> >
{decor} {decor}

View File

@ -56,7 +56,7 @@ function SlateLeaf({ leaf }: { leaf: SlateText }) {
children = <em className="italic">{children}</em> children = <em className="italic">{children}</em>
} }
if (leaf.code) { if (leaf.code) {
children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-lg font-mono text-xs text-primary font-bold">{children}</code> children = <code className="bg-primary/10 px-1.5 py-0.5 rounded-control font-mono text-xs text-primary font-bold">{children}</code>
} }
return <span>{children}</span> return <span>{children}</span>

View File

@ -3,28 +3,56 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고 /*
// variant 로 통일한다. 색·크기 미세조정은 className 으로 합성. * 랜딩 텍스트 스케일 단일 소스. 페이지마다 text-[44px]/font-black/…을 직접 박지 말고
* variant 로 통일한다. 색·크기 미세조정은 className 으로 합성.
*
* 값은 레퍼런스(statworx.com) 실측에서 가져왔다. 핵심 규칙 셋:
*
* 1) 크기·굵기·조임은 같이 움직인다. 44px 에 900 은 뭉툭하지만, 72px 에 800 +
* 자간 -0.044em + 행간 1.0 은 확신으로 읽힌다(jitter.video 실측: 72px/800/-0.044em/0.95).
* 굵기만 올리면 둔해지고, 크기만 올리면 헐거워진다.
* 2) 한글 줄바꿈은 break-keep 만으로 부족하다. break-keep 은 어절 안에서 안 쪼개지게
* 할 뿐 어디서 넘어갈지는 못 정해서 "에이전트는 기준 / 밖으로 안 나갑니다" 같은
* 어색한 분절이 나온다. text-balance 를 같이 걸어 줄 길이를 고르게 맞춘다.
* 3) 크면 조인다. 디스플레이는 자간 -0.042em·행간 1.0 까지 조이고, 작아질수록 푼다.
* 단 한글은 행간 0.95 를 못 쓴다 — 라틴은 어센더·디센더가 여백을 만들지만
* 한글은 네모틀을 꽉 채워 줄이 붙는다. 라틴 기준 0.95~1.0 을 그대로 쓰면
* 두 줄짜리 한글 헤드라인이 서로 닿아 답답해진다. 1.07 이 붙지도 벌어지지도 않는 지점.
* 3) 라벨은 작고 자간은 normal. 레퍼런스 아이브로우가 10.4px/600/자간 normal 이다.
* 14px + tracking-wider + 알약 배경 + 반짝이 아이콘 조합이 전형적인 AI 생성 티다.
*
* 한글 보정: 본문은 레퍼런스(13.9px)보다 키운다. 한글은 14px 미만에서 판독성이 급격히
* 떨어진다. 대신 디스플레이를 더 키워 대비(디스플레이:본문 ≈ 4.5:1)를 만든다.
*/
const typographyVariants = cva("", { const typographyVariants = cva("", {
variants: { variants: {
variant: { variant: {
// 히어로 h1 // 라이트 섹션 h1
display: "text-3xl sm:text-4xl md:text-[50px] font-black tracking-tight leading-[1.2] text-ink break-keep", display: "text-[44px] sm:text-[58px] md:text-[68px] font-extrabold tracking-[-0.04em] leading-[1.14] text-ink break-keep text-balance",
// 다크 무대 히어로 h1. 색은 쓰는 쪽에서 준다(2톤 헤드라인).
stageDisplay:
"text-[40px] sm:text-[56px] md:text-[70px] lg:text-[80px] font-extrabold tracking-[-0.042em] leading-[1.07] break-keep text-balance",
// 섹션 제목 h2 // 섹션 제목 h2
title: "text-3xl md:text-[44px] font-black tracking-tight leading-tight text-ink break-keep", /* 본문(15px)의 4.3배. 대비는 굵기가 아니라 크기로 만든다 —
굵기를 올리면 뭉툭해지고, 크기를 올리면 확신이 생긴다.
커질수록 자간을 더 조인다: 52px 에서 적당한 -0.021em 도 64px 에선 헐거워 보인다. */
title: "text-[34px] md:text-[64px] font-extrabold tracking-[-0.042em] leading-[1.16] text-ink break-keep text-balance",
// 섹션 내 서브 제목 (데모 패널 등) // 섹션 내 서브 제목 (데모 패널 등)
heading: "text-2xl md:text-3xl font-bold tracking-tight text-ink break-keep", heading: "text-[24px] md:text-[32px] font-bold tracking-[-0.034em] leading-[1.14] text-ink break-keep text-balance",
// 카드 제목 // 카드 제목
cardTitle: "text-lg font-bold text-ink break-keep", cardTitle: "text-[20px] font-bold tracking-[-0.028em] leading-[1.3] text-ink break-keep",
// 섹션 상단 오버라인 라벨 /* 섹션 상단 라벨. 영문 전용 — 한글 내용은 title 이 맡고, 여기는 조판 요소다.
eyebrow: "text-sm font-semibold text-primary tracking-wider uppercase", 작은 대문자 산세리프 라벨은 어느 SaaS 랜딩에나 있다. 세리프 이탤릭으로 두면
같은 자리에서 장식이 아니라 편집 디자인처럼 읽힌다. 한글은 넣지 않는다. */
eyebrow: "font-display italic text-[24px] md:text-[30px] font-normal text-primary tracking-[0.005em]",
// 섹션 리드 문단 // 섹션 리드 문단
lead: "text-base sm:text-lg text-ink-soft font-medium leading-relaxed break-keep", lead: "text-[16px] sm:text-[17px] text-ink-soft font-normal leading-[1.6] break-keep",
body: "text-[15px] text-ink-soft font-medium leading-relaxed break-keep", body: "text-[15px] text-ink-soft font-normal leading-[1.6] break-keep",
small: "text-sm text-ink-soft font-medium leading-relaxed break-keep", small: "text-[14px] text-ink-soft font-normal leading-[1.6] break-keep",
caption: "text-xs text-ink-muted font-semibold leading-relaxed break-keep", caption: "text-[12.5px] text-ink-muted font-normal leading-[1.55] break-keep",
// 11px 트래킹 대문자 메타 라벨 (단가 패널·푸터 컬럼 제목 등) // 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
micro: "text-[11px] font-bold text-ink-muted uppercase tracking-wider", micro: "text-[11px] font-semibold text-ink-muted tracking-normal",
}, },
}, },
defaultVariants: { variant: "body" }, defaultVariants: { variant: "body" },
@ -32,6 +60,7 @@ const typographyVariants = cva("", {
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = { const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
display: "h1", display: "h1",
stageDisplay: "h1",
title: "h2", title: "h2",
heading: "h3", heading: "h3",
cardTitle: "h3", cardTitle: "h3",

View File

@ -0,0 +1,177 @@
/**
* 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용).
*
* 실제 negotium 엔진(agent 서비스)의 판정 규칙을 그대로 옮겼다. 데모가 제품과 다른
* 규칙으로 돌면 상담 자리에서 거짓말이 되기 때문에, 단순화는 하되 규칙은 바꾸지 않는다.
*
* - 앵커링가(anchor) 이하 제시는 무조건 수락한다.
* - 앵커 위면 협상 카드를 한 장 쓰고 역제안한다. 카드는 3장.
* - 카드를 다 쓰고도 앵커 위면 개찰(낙찰자 미정 마감)이다. 결렬이 아니다.
*
* React 를 모른다. 나중에 서버의 실엔진(/api/demo/negotiate)으로 갈아끼울 때
* 이 파일의 함수 시그니처만 유지하면 컴포넌트는 손대지 않아도 된다.
*/
export type Role = "buyer" | "seller"
export type DemoItem = {
id: string
name: string
spec: string
/** 협력사 최초 제시가 */
listPrice: number
/** LPS 인터넷 최저가 — 에이전트가 근거로 인용한다 */
marketLow: number
/** 앵커링가. 이 이하면 무조건 낙찰 */
anchor: number
/** 견적 생성 때 정한 목표가 */
target: number
}
export const MAX_CARDS = 3
export const DEMO_ITEMS: DemoItem[] = [
{
id: "glove",
name: "니트릴 코팅 안전장갑",
spec: "1,000켤레 · 월 정기",
listPrice: 1_200_000,
marketLow: 1_110_000,
anchor: 1_020_000,
target: 1_080_000,
},
{
id: "paper",
name: "A4 복사용지 80g",
spec: "500박스 · 분기 발주",
listPrice: 8_750_000,
marketLow: 8_200_000,
anchor: 7_900_000,
target: 8_100_000,
},
{
id: "oil",
name: "산업용 윤활유 20L",
spec: "300통 · 반기 발주",
listPrice: 5_400_000,
marketLow: 5_050_000,
anchor: 4_850_000,
target: 4_980_000,
},
]
export type Turn = {
id: number
side: "agent" | "counterpart"
text: string
/** 이 턴에서 테이블에 올라온 가격 */
price?: number
}
export type Outcome = "running" | "award" | "open"
/** 협상에서 실제로 오가는 조건들. 가격만 깎는 게 아니라는 걸 보여주는 장치. */
const LEVERS = ["연간 물량 보증", "납기 2주 연장", "대금 지급일 15일 단축"] as const
/** 10원 단위 반올림 — 실제 엔진의 앵커링 정돈 규칙과 같다. */
export const round10 = (n: number) => Math.round(n / 10) * 10
export const won = (n: number) => n.toLocaleString("ko-KR")
/**
* 에이전트의 양보 곡선 — 이 데모의 성격을 결정하는 단 하나의 숫자 뭉치다.
*
* 카드를 쓸수록 앵커에서 상대 제시가 쪽으로 얼마나 올라올지를 정한다.
* 0 이면 끝까지 앵커를 고수하고, 1 이면 즉시 상대 제시가를 받는다.
* 앞 카드를 낮게 잡을수록 강하게 버티는 에이전트가 된다.
*/
const CONCESSION = [0, 0.34, 0.62]
/** 협력사(사용자) 제시가에 대한 에이전트의 판정. seller 모드의 핵심. */
export function respondToOffer(
item: DemoItem,
offer: number,
cardsUsed: number,
): { outcome: Outcome; price: number; text: string } {
if (offer <= item.anchor) {
return {
outcome: "award",
price: offer,
text: `${won(offer)}원으로 확정하겠습니다. 목표가 ${won(item.target)}원 대비 ${won(
item.target - offer,
)}원 낮습니다. 주고받은 제안은 전부 기록에 남습니다.`,
}
}
if (cardsUsed >= MAX_CARDS) {
return {
outcome: "open",
price: offer,
text: `${won(offer)}원은 이번 견적의 낙찰 기준을 넘습니다. 협상 카드를 다 썼으니 이 건은 개찰로 마감하고 담당자에게 넘기겠습니다.`,
}
}
const ask = round10(item.anchor + (offer - item.anchor) * CONCESSION[cardsUsed])
const lever = LEVERS[cardsUsed]
const reason =
cardsUsed === 0
? `같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다.`
: `${won(offer)}원과 저희 기준 사이가 아직 ${won(offer - ask)}원 남았습니다.`
return {
outcome: "running",
price: ask,
text: `${reason} ${won(ask)}원까지 맞춰주시면 ${lever}으로 보전해 드리겠습니다.`,
}
}
/**
* buyer 모드 — 사용자가 목표가만 정하면 에이전트가 협력사와 알아서 붙는다.
* 전체 턴을 미리 만들어 두고 화면에서 하나씩 재생한다.
*/
export function simulateBuyerRun(item: DemoItem, target: number): { turns: Turn[]; outcome: Outcome; finalPrice: number } {
const turns: Turn[] = []
let id = 0
const push = (side: Turn["side"], text: string, price?: number) => turns.push({ id: id++, side, text, price })
push("counterpart", `원자재가 올라서 이번 분기는 ${won(item.listPrice)}원이 최선입니다.`, item.listPrice)
// 목표가가 앵커보다 낮으면 협력사가 받아들일 수 없는 구간이다.
// 에이전트는 기준 밖으로 나가지 않으므로 개찰로 끝난다 — 제품의 안전장치를 보여주는 케이스다.
const reachable = target >= item.anchor
push(
"agent",
`같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`,
item.marketLow,
)
const mid = round10((item.listPrice + Math.max(target, item.anchor)) / 2)
push("counterpart", `공정상 한 번에 내리긴 어렵고, ${won(mid)}원까지는 조정하겠습니다.`, mid)
if (!reachable) {
push(
"agent",
`목표가 ${won(target)}원은 협력사가 받아들일 수 있는 선 아래입니다. 무리하게 밀지 않고 이 건은 개찰로 마감하겠습니다.`,
mid,
)
return { turns, outcome: "open", finalPrice: mid }
}
push("agent", `${won(target)}원이면 연 12회 정기 발주로 확정하겠습니다. 대신 납기는 2주 여유를 드리겠습니다.`, target)
push("counterpart", `정기 발주와 결제 조건을 지켜주신다면 ${won(target)}원으로 맞추겠습니다.`, target)
push(
"agent",
`${won(target)}원으로 확정했습니다. 최초 제시가 대비 ${won(item.listPrice - target)}원 절감입니다.`,
target,
)
return { turns, outcome: "award", finalPrice: target }
}
/** 절감액·절감률. 결과 화면과 상담 신청 연결에 쓴다. */
export function savings(item: DemoItem, finalPrice: number) {
const amount = Math.max(0, item.listPrice - finalPrice)
return { amount, rate: (amount / item.listPrice) * 100 }
}

View File

@ -8,6 +8,9 @@ export const links: Route.LinksFunction = () => [
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" }, { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화 // 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
{ rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" }, { rel: "preload", href: "/fonts/PretendardVariable.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
/* 영문 디스플레이 서체는 self-host 한다(latin 서브셋 39KB).
구글 폰트 CDN 을 쓰면 서드파티 요청이 LCP 를 흔들고 방문자 IP 가 외부로 나간다.
preload 는 걸지 않는다 — 라벨 전용이라 폴드 위 렌더를 막을 이유가 없다. */
]; ];
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {

View File

@ -5,14 +5,14 @@ import { Faq } from "@/components/sections/faq";
import { FinalCTA } from "@/components/sections/final-cta"; import { FinalCTA } from "@/components/sections/final-cta";
import { Footer } from "@/components/sections/footer"; import { Footer } from "@/components/sections/footer";
import { Header } from "@/components/sections/header"; import { Header } from "@/components/sections/header";
import { HeroNeumorphic } from "@/components/sections/hero-neumorphic"; import { HeroDataFlow } from "@/components/sections/hero-dataflow";
import { HowItWorksDemo } from "@/components/sections/how-it-works-demo"; import { HowItWorksDemo } from "@/components/sections/how-it-works-demo";
import { NegotiationConsole } from "@/components/sections/negotiation-console"; import { NegotiationDemo } from "@/components/sections/negotiation-demo";
import { Reinforcement } from "@/components/sections/reinforcement"; import { Reinforcement } from "@/components/sections/reinforcement";
const TITLE = "negotium — AI 구매 협상 자동화"; const TITLE = "negotium — AI 구매 협상 자동화";
const DESCRIPTION = const DESCRIPTION =
"가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션."; "품목·목표가·마감일만 정하면 협상 에이전트가 협력사마다 1:1로 단가를 조율하고 낙찰까지 판정합니다. 협상 기록이 쌓일수록 조건이 좋아지는 B2B 구매 협상 자동화 솔루션.";
export function meta() { export function meta() {
return [ return [
@ -30,8 +30,8 @@ export default function Home() {
return ( return (
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary"> <div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
<Header /> <Header />
<HeroNeumorphic /> <HeroDataFlow />
<NegotiationConsole /> <NegotiationDemo />
<HowItWorksDemo /> <HowItWorksDemo />
<Reinforcement /> <Reinforcement />
<CoreValues /> <CoreValues />

Binary file not shown.