[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:
parent
3c3f60c7d4
commit
6785c8a6f4
@ -10,38 +10,101 @@
|
||||
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 …)만 쓴다.
|
||||
*
|
||||
* 구조는 두 겹이다.
|
||||
* - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다.
|
||||
* - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지.
|
||||
*/
|
||||
@theme {
|
||||
--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;
|
||||
/* 영문 디스플레이 — 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다
|
||||
(Playfair 는 한글 글리프가 없어 Pretendard 로 폴백되며 톤이 깨진다). */
|
||||
--font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif;
|
||||
|
||||
/* 브랜드 */
|
||||
--color-primary: #476EFF;
|
||||
--color-primary-deep: #2F52E0; /* hover */
|
||||
--color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */
|
||||
/* ── 브랜드 액센트 ──────────────────────────────────────────────
|
||||
여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)로
|
||||
되돌리려면 이 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */
|
||||
--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;
|
||||
--color-ink-soft: #4E5968; /* 보조 본문 */
|
||||
--color-ink-muted: #8B95A1; /* 캡션 */
|
||||
--color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */
|
||||
/* ── 상대편(공급사) ────────────────────────────────────────────
|
||||
협상은 두 주체가 매 턴 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라
|
||||
구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다.
|
||||
NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */
|
||||
--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-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */
|
||||
--color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */
|
||||
--color-fill-hover: #EAECEF;
|
||||
--color-line: #F2F4F6; /* 섹션 구분선 */
|
||||
--color-line-strong: #E5E8EB; /* 카드 테두리 */
|
||||
--color-surface: #F7F8FC; /* 회색 섹션 배경 */
|
||||
--color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */
|
||||
--color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */
|
||||
--color-fill-hover: #E6E9F2;
|
||||
--color-line: #EEF0F6; /* 섹션 구분선 */
|
||||
--color-line-strong: #E0E4EE; /* 카드 테두리 */
|
||||
|
||||
--color-positive: #0B8F57; /* 낙찰·성공 강조 */
|
||||
--color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */
|
||||
|
||||
--color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */
|
||||
--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 {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "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 { SectionHeading } from "@/components/ui/section-heading"
|
||||
@ -47,7 +47,7 @@ export function Comparison() {
|
||||
<motion.div {...fadeUp} className="mb-12">
|
||||
<SectionHeading
|
||||
align="center"
|
||||
eyebrow="WHY NEGOTIUM"
|
||||
eyebrow="Before & After"
|
||||
title="협상 방식을 바꾸면, 무엇이 달라질까요"
|
||||
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
|
||||
descriptionClassName="mt-4 text-[17px] max-w-2xl"
|
||||
@ -56,16 +56,17 @@ export function Comparison() {
|
||||
|
||||
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */}
|
||||
<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) => (
|
||||
<button
|
||||
key={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 ${
|
||||
mode === m.key ? "bg-white text-primary shadow-sm" : "text-ink-soft hover:text-ink"
|
||||
className={`px-7 py-3 rounded-full text-[15px] font-semibold transition-colors cursor-pointer ${
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
@ -97,13 +98,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind
|
||||
return (
|
||||
<motion.div
|
||||
{...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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
@ -156,14 +157,14 @@ const ITEMS: CompareItem[] = [
|
||||
{
|
||||
label: "사람",
|
||||
icon: UserRound,
|
||||
before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.",
|
||||
after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.",
|
||||
before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.",
|
||||
after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.",
|
||||
},
|
||||
{
|
||||
label: "파트너 관계",
|
||||
icon: Handshake,
|
||||
before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.",
|
||||
after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.",
|
||||
before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.",
|
||||
after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.",
|
||||
},
|
||||
{
|
||||
label: "의사 결정",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "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 { Input, Textarea } from "@/components/ui/input"
|
||||
@ -8,7 +8,6 @@ import { Section } from "@/components/ui/section"
|
||||
import { SectionHeading } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
const EMPTY_FORM = {
|
||||
companyName: '',
|
||||
@ -41,22 +40,13 @@ export function Contact() {
|
||||
const submitting = status === 'submitting'
|
||||
|
||||
return (
|
||||
<Section
|
||||
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" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Section id="contact-section" bordered>
|
||||
<SectionHeading
|
||||
align="center"
|
||||
className="mb-16"
|
||||
eyebrow="GET IN TOUCH"
|
||||
title="도입 문의 및 컨택하기"
|
||||
description="사내 ERP 연동부터 우리 기업에 맞춘 흥정 시나리오 구성까지, negotium의 구매 혁신 컨설턴트가 상세히 안내해 드립니다."
|
||||
eyebrow="Get in Touch"
|
||||
title="도입 문의"
|
||||
description="품목과 협력사 규모를 알려주시면, 예상 절감 구간과 도입 절차를 정리해 드립니다."
|
||||
descriptionClassName="max-w-xl"
|
||||
/>
|
||||
|
||||
@ -70,10 +60,10 @@ export function Contact() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
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">
|
||||
<FormField label="회사명" icon={Building} required>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
|
||||
<FormField label="회사명" required>
|
||||
<Input
|
||||
type="text"
|
||||
name="companyName"
|
||||
@ -98,8 +88,8 @@ export function Contact() {
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<FormField label="이메일 주소" icon={Mail} required>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-9">
|
||||
<FormField label="이메일 주소" required>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
@ -111,7 +101,7 @@ export function Contact() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="연락처" icon={Phone} required>
|
||||
<FormField label="연락처" required>
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
@ -128,7 +118,7 @@ export function Contact() {
|
||||
<Textarea
|
||||
name="message"
|
||||
rows={4}
|
||||
placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다."
|
||||
placeholder="협상 자동화를 검토 중인 품목이나 현재 겪는 어려움을 적어주세요."
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
@ -136,22 +126,18 @@ export function Contact() {
|
||||
</FormField>
|
||||
|
||||
<Typography variant="caption" className="font-medium">
|
||||
* 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게
|
||||
보호됩니다.
|
||||
입력하신 정보는 문의 응답과 도입 검토 목적으로만 사용됩니다.
|
||||
</Typography>
|
||||
|
||||
<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 ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>신청서 전송 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
<span>도입 문의 및 상담 신청하기</span>
|
||||
</>
|
||||
<span>상담 신청</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@ -162,15 +148,14 @@ export function Contact() {
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
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">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
<div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Check className="w-6 h-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-extrabold text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
|
||||
<p className="text-ink-soft font-semibold text-[15px] leading-relaxed max-w-md mx-auto mb-8">
|
||||
작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로
|
||||
연락을 드리겠습니다.
|
||||
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
|
||||
<p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
|
||||
{formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@ -190,22 +175,14 @@ export function Contact() {
|
||||
)
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
icon: Icon,
|
||||
required = false,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
required?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
/* 라벨 옆 작은 아이콘은 정보를 더하지 않는다 — "회사명" 옆의 빌딩 아이콘이 알려주는 건
|
||||
이미 글자가 말한 것뿐이고, 필드마다 반복되면 폼이 산만해진다. 글자만 남긴다. */
|
||||
function FormField({ label, required = false, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-ink-soft flex items-center gap-1.5">
|
||||
{Icon && <Icon className="w-3.5 h-3.5 text-ink-muted" />}
|
||||
{label} {required && <span className="text-primary">*</span>}
|
||||
<div className="space-y-2.5">
|
||||
<label className="block text-[14px] font-medium text-ink">
|
||||
{label}
|
||||
{required && <span className="text-primary ml-1" aria-hidden>*</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,7 @@ export function CoreValues() {
|
||||
<motion.div {...fadeUp} className="mb-24">
|
||||
<SectionHeading
|
||||
align="center"
|
||||
eyebrow="BEYOND EXPECTED VALUE"
|
||||
eyebrow="Beyond Price"
|
||||
title="가격 그 이상의 이점"
|
||||
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
|
||||
descriptionClassName="mt-4 text-[17px]"
|
||||
@ -37,7 +37,7 @@ function ValueStrip({ value, delay }: { value: Value; delay: number }) {
|
||||
const fadeUp = useFadeUp(delay)
|
||||
|
||||
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">
|
||||
<value.icon className="w-5 h-5" />
|
||||
</div>
|
||||
@ -56,7 +56,7 @@ const VALUES: Value[] = [
|
||||
icon: Users,
|
||||
title: "파트너사 관계 수호",
|
||||
description:
|
||||
"단가를 깎는 악역과 감정 소모는 봇이 맡습니다. 담당자는 협력사와의 신뢰와 동반 성장, 큰 틀의 파트너십에만 집중하세요.",
|
||||
"단가 조율은 에이전트가 정해진 기준으로 반복합니다. 담당자는 협력사와의 신뢰와 동반 성장에 집중할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
@ -68,7 +68,7 @@ const VALUES: Value[] = [
|
||||
icon: Briefcase,
|
||||
title: "핵심 전략에만 집중",
|
||||
description:
|
||||
"이메일·메신저로 반복되던 흥정 수작업은 봇이 전담합니다. 인력은 공급망 위기 대처, 우량 공급처 발굴 같은 전략 업무와 대형 거래에 투입하세요.",
|
||||
"이메일·메신저로 오가던 단가 협의는 에이전트가 전담합니다. 인력은 공급망 위기 대응과 신규 공급처 발굴에 투입하세요.",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
|
||||
@ -55,7 +55,7 @@ function FaqItem({
|
||||
const fadeUp = useFadeUp(delay)
|
||||
|
||||
return (
|
||||
<motion.div {...fadeUp} className="bg-surface rounded-[28px] overflow-hidden">
|
||||
<motion.div {...fadeUp} className="bg-surface rounded-card overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
@ -96,14 +96,14 @@ function FaqItem({
|
||||
|
||||
const FAQS: FaqEntry[] = [
|
||||
{
|
||||
question: "공급사가 봇과의 협상을 싫어하지 않을까요?",
|
||||
question: "협력사가 에이전트와의 협상을 꺼리지 않을까요?",
|
||||
answer:
|
||||
"오히려 반대입니다. 이 거래들 대부분은 지금껏 협상 테이블에 오르지도 못하던 건입니다. 봇은 24시간 원하는 시간에, 압박 없이, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%에 이릅니다.",
|
||||
"이 건들 대부분은 지금껏 협상 테이블에 오르지도 못하던 거래입니다. 에이전트는 협력사가 편한 시간에, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%로 보고됩니다.",
|
||||
},
|
||||
{
|
||||
question: "봇이 우리 기준을 벗어나 합의해 버리면요?",
|
||||
question: "에이전트가 우리 기준을 벗어나 합의해 버리면요?",
|
||||
answer:
|
||||
"그럴 수 없습니다. 봇은 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
|
||||
"그럴 수 없습니다. 에이전트는 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
|
||||
},
|
||||
{
|
||||
question: "기존 시스템과 연동되나요?",
|
||||
|
||||
@ -2,56 +2,45 @@ import { motion } from "motion/react"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
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() {
|
||||
const fadeUp = useFadeUp()
|
||||
|
||||
return (
|
||||
<section className="relative bg-ink text-white py-36 md:py-48 overflow-hidden">
|
||||
{/* 다크 섹션 중앙의 은은한 파란 글로우 */}
|
||||
<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" />
|
||||
<section className="stage-bg text-on-stage py-24 md:py-32">
|
||||
{/* max-w-3xl 이면 "조용히 새고 있을지 / 모릅니다" 로 끊겨 마지막 줄이 고아가 된다. */}
|
||||
<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">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
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>
|
||||
<Typography variant="stageDisplay" as="h2" className="text-[32px] sm:text-[44px] md:text-[56px]">
|
||||
<span className="text-white">지금도 구매 예산은</span>
|
||||
<br />
|
||||
<span className="text-on-stage-soft/60">조용히 새고 있을지 모릅니다</span>
|
||||
</Typography>
|
||||
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
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>
|
||||
<p className="mt-8 text-[16px] sm:text-[17px] text-on-stage-soft/75 max-w-xl mx-auto leading-[1.6] break-keep">
|
||||
협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. negotium 에이전트가 정해진 기준 안에서 그 건들을 대신
|
||||
조율합니다.
|
||||
</p>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-[17px] text-ink-muted max-w-xl mx-auto mb-14 leading-relaxed font-semibold break-keep"
|
||||
>
|
||||
수작업으로 진행되어 수많은 단가 타협 기회를 흘려보내던 파트너사 흥정, 규칙 준수에 압도적으로 특화된 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>
|
||||
<div className="mt-12">
|
||||
<Button href="#contact-section" variant="stage" size="xl" className="group">
|
||||
<span>도입 상담 신청</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -8,7 +8,7 @@ export function Footer() {
|
||||
<div className="md:col-span-8">
|
||||
<Logo className="mb-6" />
|
||||
<Typography variant="caption" className="max-w-sm mb-6 font-medium">
|
||||
negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
|
||||
negotium은 협상 에이전트가 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.
|
||||
</Typography>
|
||||
<p className="text-xs text-ink-faint font-semibold">
|
||||
© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved.
|
||||
|
||||
@ -5,36 +5,78 @@ import { Button } from "@/components/ui/button"
|
||||
import { Logo } from "@/components/ui/logo"
|
||||
|
||||
export function Header() {
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
// 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다.
|
||||
// compact — 조금이라도 스크롤하면 높이를 줄인다.
|
||||
// onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다.
|
||||
const [compact, setCompact] = useState(false)
|
||||
const [onStage, setOnStage] = useState(true)
|
||||
|
||||
// 스크롤 시 헤더를 반투명 블러 배경으로 전환
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 40)
|
||||
const handleScroll = () => setCompact(window.scrollY > 40)
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
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 (
|
||||
<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">
|
||||
<a href="#">
|
||||
<Logo />
|
||||
<Logo className={onStage ? "brightness-0 invert" : undefined} />
|
||||
</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 }) => (
|
||||
<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}
|
||||
</a>
|
||||
))}
|
||||
</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>
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
82
landing/app/components/sections/hero-dataflow.tsx
Normal file
82
landing/app/components/sections/hero-dataflow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "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 { Section } from "@/components/ui/section"
|
||||
@ -18,20 +18,20 @@ export function HowItWorksDemo() {
|
||||
<SectionHeading
|
||||
align="center"
|
||||
className="mb-20"
|
||||
eyebrow="SERVICE DEMONSTRATION"
|
||||
eyebrow="How it Works"
|
||||
title="견적 생성부터 낙찰까지 한눈에 보기"
|
||||
description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요."
|
||||
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
|
||||
descriptionClassName="text-[17px] max-w-2xl"
|
||||
/>
|
||||
|
||||
{/* 탭 스위치 */}
|
||||
<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) => (
|
||||
<button
|
||||
key={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"
|
||||
}`}
|
||||
>
|
||||
@ -54,10 +54,9 @@ export function HowItWorksDemo() {
|
||||
transition={{ duration: 0.4 }}
|
||||
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" />
|
||||
{activeTab.badge.label}
|
||||
</div>
|
||||
{/* 알약 배경 + 아이콘을 뺀 글자만의 라벨.
|
||||
레퍼런스 라벨은 11px/600/자간 normal 에 배경도 아이콘도 없다. */}
|
||||
<div className={`text-[11px] font-semibold ${activeTab.badge.className}`}>{activeTab.badge.label}</div>
|
||||
<Typography variant="heading">{activeTab.heading}</Typography>
|
||||
{activeTab.paragraphs.map((paragraph, idx) => (
|
||||
<Typography key={idx} variant="body">
|
||||
@ -159,7 +158,7 @@ type DemoTab = {
|
||||
key: "create" | "negotiate" | "result"
|
||||
tabIcon: LucideIcon
|
||||
tabLabel: string
|
||||
badge: { icon: LucideIcon; label: string; className: string }
|
||||
badge: { label: string; className: string }
|
||||
checkClassName: string
|
||||
heading: React.ReactNode
|
||||
paragraphs: React.ReactNode[]
|
||||
@ -183,19 +182,19 @@ const DEMO_TABS: DemoTab[] = [
|
||||
{
|
||||
key: "create",
|
||||
tabIcon: Monitor,
|
||||
tabLabel: "1단계: AI 견적 & 가이드 수립",
|
||||
badge: { icon: Sparkles, label: "구매 관리자 콘솔 (웹)", className: "bg-primary-soft text-primary" },
|
||||
tabLabel: "1. 견적 · 기준 수립",
|
||||
badge: { label: "구매 관리자 콘솔", className: "text-primary" },
|
||||
checkClassName: "text-primary",
|
||||
heading: (
|
||||
<>
|
||||
엑셀 등록만으로 끝나는 <br />
|
||||
초정밀 가이드라인 자율 수립
|
||||
엑셀만 올리면 <br />
|
||||
목표 단가가 잡힙니다
|
||||
</>
|
||||
),
|
||||
paragraphs: [
|
||||
<>
|
||||
품목명과 시중 시장 가격을 입력하면, negotium이 <b>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
|
||||
<b className="text-primary">시장 바닥값을 쥐고 시작합니다.</b>
|
||||
<b className="text-primary">시장 최저가를 기준으로 시작합니다.</b>
|
||||
</>,
|
||||
<>
|
||||
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
|
||||
@ -211,7 +210,7 @@ const DEMO_TABS: DemoTab[] = [
|
||||
gifAlt: "견적 생성 가이드라인 수립 시연",
|
||||
fallback: {
|
||||
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",
|
||||
title: "견적 생성 시연 GIF 공간",
|
||||
hint: "실제 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||
@ -220,8 +219,8 @@ const DEMO_TABS: DemoTab[] = [
|
||||
{
|
||||
key: "negotiate",
|
||||
tabIcon: Smartphone,
|
||||
tabLabel: "2단계: AI 자동 밀당 협상",
|
||||
badge: { icon: Bot, label: "협력사 협상 포털 (웹·모바일)", className: "bg-positive-soft border border-positive/15 text-positive" },
|
||||
tabLabel: "2. 협력사별 1:1 협상",
|
||||
badge: { label: "협력사 협상 포털", className: "text-positive" },
|
||||
checkClassName: "text-positive",
|
||||
heading: (
|
||||
<>
|
||||
@ -234,14 +233,14 @@ const DEMO_TABS: DemoTab[] = [
|
||||
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
|
||||
</>,
|
||||
<>
|
||||
단순 마진 깎기가 아닙니다. 봇은 준비된 협상 카드를 상황에 맞게 꺼내, 파트너사의 자발적인 마진 타협을 부드럽게
|
||||
단순히 깎는 게 아닙니다. 에이전트는 준비된 협상 카드를 상황에 맞게 꺼내, 협력사가 받아들일 조건을
|
||||
이끌어냅니다.
|
||||
</>,
|
||||
],
|
||||
checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
|
||||
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
|
||||
mockup: "phone",
|
||||
gif: "/gifs/negotiation.gif",
|
||||
gifAlt: "AI 모바일 자동 협상 시연",
|
||||
gifAlt: "협력사 포털 모바일 협상 화면",
|
||||
fallback: {
|
||||
icon: Smartphone,
|
||||
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",
|
||||
tabIcon: Monitor,
|
||||
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",
|
||||
heading: (
|
||||
<>
|
||||
@ -280,7 +279,7 @@ const DEMO_TABS: DemoTab[] = [
|
||||
gifAlt: "협상 결과 분석 대시보드 시연",
|
||||
fallback: {
|
||||
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",
|
||||
title: "협상 결과 확인 시연 GIF 공간",
|
||||
hint: "결과 보고 대시보드 기동 GIF를 배치하시면 이 영역에 자동으로 재생됩니다.",
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
371
landing/app/components/sections/negotiation-demo.tsx
Normal file
371
landing/app/components/sections/negotiation-demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -11,18 +11,18 @@ export function Reinforcement() {
|
||||
const fadeUp = useFadeUp()
|
||||
|
||||
return (
|
||||
<Section id="reinforcement" bg="surface" bordered className="border-b border-line">
|
||||
<Section id="reinforcement" bg="stage">
|
||||
<motion.div {...fadeUp} className="mb-20">
|
||||
<SectionHeading
|
||||
className="text-center md:text-left"
|
||||
eyebrow="REINFORCEMENT LEARNING"
|
||||
tone="stage"
|
||||
eyebrow="Reinforcement Learning"
|
||||
title="협상할수록, 더 좋은 조건으로"
|
||||
description={
|
||||
<>
|
||||
잘 깎는 노하우는 담당자 머릿속에만 남고, 성과는 그날의 감정과 컨디션에 흔들립니다. negotium은 진행된 모든 거래
|
||||
데이터와 파트너 거절 피드백을 <span className="font-bold text-primary">강화학습 기술</span>에 투입합니다.
|
||||
무턱대고 가격을 후려쳐 상대방의 반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수
|
||||
있는 범위 내에서 최선의 마진율을 뽑아내도록 조율 페이스를 학습합니다.
|
||||
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
|
||||
협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
|
||||
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
|
||||
</>
|
||||
}
|
||||
descriptionClassName="mt-8 max-w-3xl"
|
||||
@ -44,15 +44,15 @@ function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) {
|
||||
const fadeUp = useFadeUp(delay)
|
||||
|
||||
return (
|
||||
<motion.div {...fadeUp} className="bg-white p-8 rounded-[28px] 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">
|
||||
<pillar.icon className="w-6 h-6" />
|
||||
<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-11 h-11 rounded-control bg-primary/15 text-on-stage flex items-center justify-center">
|
||||
<pillar.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="cardTitle" className="mb-2">
|
||||
<Typography variant="cardTitle" className="mb-2 text-on-stage">
|
||||
{pillar.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="text-ink-soft">
|
||||
<Typography variant="caption" className="text-on-stage-soft">
|
||||
{pillar.description}
|
||||
</Typography>
|
||||
</div>
|
||||
@ -68,12 +68,12 @@ const PILLARS: Pillar[] = [
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
title: "지속적인 전략 최적화",
|
||||
description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.",
|
||||
title: "쓸수록 올라가는 기준선",
|
||||
description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.",
|
||||
},
|
||||
{
|
||||
icon: GitMerge,
|
||||
title: "낙찰 성사율 극대화",
|
||||
description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.",
|
||||
title: "결렬 없는 합의",
|
||||
description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.",
|
||||
},
|
||||
]
|
||||
|
||||
@ -25,7 +25,7 @@ export function ROISimulator() {
|
||||
<SectionHeading
|
||||
align="center"
|
||||
className="mb-24"
|
||||
eyebrow="ROI SIMULATION"
|
||||
eyebrow="ROI Simulation"
|
||||
title={
|
||||
<>
|
||||
negotium 도입 시 <br />
|
||||
@ -65,7 +65,7 @@ export function ROISimulator() {
|
||||
<label className="text-[16px] font-bold text-ink">관리 중인 총 협력사 수</label>
|
||||
<span className="text-lg font-black text-primary font-mono">{suppliers}개사</span>
|
||||
</div>
|
||||
<Typography variant="caption">AI 봇 "밀당"이 동시에 일대일로 자동 조율할 잠재 공급사 수량입니다.</Typography>
|
||||
<Typography variant="caption">에이전트가 동시에 1:1로 조율할 협력사 수입니다.</Typography>
|
||||
<div className="pt-2">
|
||||
<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">
|
||||
@ -76,11 +76,11 @@ export function ROISimulator() {
|
||||
</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" />
|
||||
<Typography variant="caption" className="text-ink-soft">
|
||||
본 시뮬레이션은 글로벌 최저가 스캔(LPS) 시장 표준 및 다수 B2B 자율 협상 데이터의 보수적인 평균 수치(3.1% ~ 4.2%
|
||||
절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다.
|
||||
최저가 스캔(LPS) 시세와 B2B 협상 데이터의 보수적 평균(3.1~4.2% 절감)을 적용한 추정치입니다.
|
||||
실제 데이터로 검증해 보시려면 파일만 주시면 됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
@ -96,8 +96,7 @@ export function ROISimulator() {
|
||||
{formatSavings(estimatedSavingsValue)}
|
||||
</div>
|
||||
<Typography variant="caption" className="text-ink-soft font-medium pt-1">
|
||||
사각지대에 방치되던 꼬리 거래(Tail Spend)의 상시 병렬 협상 및 인터넷 실시간 앵커가 시세 연동을 통해, 기존 수동
|
||||
흥정으로 놓치고 있던 원가 이탈을 완벽히 방어해냅니다.
|
||||
그동안 손이 못 가던 꼬리 거래(Tail Spend)까지 동시에 협상합니다. 수동으로는 놓치던 구간입니다.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@ -109,7 +108,7 @@ export function ROISimulator() {
|
||||
</span>
|
||||
</div>
|
||||
<Typography variant="caption">
|
||||
메일 전송, 유선 흥정, 엑셀 취합 등에 낭비되던 소모적인 절차가 실시간 링크 전송과 AI 봇의 24시간 자율 대응으로
|
||||
메일 전송, 유선 협의, 엑셀 취합에 쓰이던 시간이 링크 한 번과 에이전트 응대로
|
||||
완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,9 @@ import { cn } from "@/lib/utils"
|
||||
// CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 <a> 로 렌더한다
|
||||
// (SSG 특성상 하이드레이션 전에도 동작).
|
||||
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: {
|
||||
variant: {
|
||||
@ -14,13 +16,21 @@ const buttonVariants = cva(
|
||||
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",
|
||||
// 다크 무대 위 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: {
|
||||
pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl",
|
||||
md: "px-6 py-2.5 text-xs font-bold rounded-2xl",
|
||||
lg: "px-8 py-4.5 text-base font-bold rounded-2xl",
|
||||
xl: "px-10 py-5 text-base font-bold rounded-2xl",
|
||||
pill: "px-5 py-2.5 text-[12px] font-semibold rounded-full",
|
||||
md: "px-6 py-3 text-[12px] font-semibold rounded-full",
|
||||
lg: "px-7 py-3.5 text-[13px] font-semibold rounded-full",
|
||||
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" },
|
||||
|
||||
466
landing/app/components/ui/data-flow-canvas.tsx
Normal file
466
landing/app/components/ui/data-flow-canvas.tsx
Normal 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} />
|
||||
}
|
||||
@ -3,13 +3,13 @@ import type * as React from "react"
|
||||
/** 데스크톱 브라우저 목업 프레임 — 신호등 버튼 + 주소창 + 16:10 워크스페이스. */
|
||||
function BrowserFrame({ url, children }: { url: string; children: React.ReactNode }) {
|
||||
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="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-[#FFBD2E] 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}
|
||||
</span>
|
||||
</div>
|
||||
@ -22,9 +22,9 @@ function BrowserFrame({ url, children }: { url: string; children: React.ReactNod
|
||||
/** 모바일 폰 목업 프레임 — 노치 포함 9:18 스크린. */
|
||||
function PhoneFrame({ children }: { children: React.ReactNode }) {
|
||||
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" />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -2,16 +2,31 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// 문의 폼 필드 공통 스타일 — 테두리 없는 회색 채움, 포커스 시 흰 배경 + 파란 링.
|
||||
/*
|
||||
* 문의 폼 필드.
|
||||
*
|
||||
* 회색 채움 박스는 어느 템플릿에나 있다. 밑줄 필드로 바꾸면 같은 기능인데 훨씬
|
||||
* 정돈돼 보인다 — 폼이 "입력 상자 더미"가 아니라 한 장의 문서처럼 읽힌다.
|
||||
*
|
||||
* 밑줄 폼의 알려진 위험은 입력란으로 안 보이는 것(어포던스 손실)인데,
|
||||
* 라벨을 항상 위에 두고 밑줄을 2px 로 두껍게 잡아 보완했다.
|
||||
* 두께를 항상 2px 로 고정해서 포커스 때 레이아웃이 흔들리지 않는다.
|
||||
*
|
||||
* 글자 16px 은 취향이 아니라 필수다 — iOS 사파리는 16px 미만 입력에 포커스하면
|
||||
* 화면을 자동 확대해서, 광고로 들어온 모바일 사용자의 폼 이탈을 만든다.
|
||||
*/
|
||||
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>) {
|
||||
return <input className={cn(fieldClass, className)} {...props} />
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
152
landing/app/components/ui/orbit-ring.tsx
Normal file
152
landing/app/components/ui/orbit-ring.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -17,7 +17,7 @@ function RangeSlider({ min, max, step, value, onChange }: RangeSliderProps) {
|
||||
step={step}
|
||||
value={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={{
|
||||
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`,
|
||||
}}
|
||||
|
||||
@ -3,25 +3,50 @@ import * as React from "react"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/*
|
||||
* 섹션 머리.
|
||||
*
|
||||
* eyebrow 는 선택이고, 기본적으로 쓰지 않는다. 작은 색 라벨을 섹션마다 반복하면
|
||||
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
|
||||
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
|
||||
* 위계는 라벨이 아니라 헤드라인 크기(본문의 3.5~4배)와 2톤 대비로 만든다.
|
||||
*/
|
||||
type SectionHeadingProps = {
|
||||
eyebrow: string
|
||||
eyebrow?: string
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
align?: "left" | "center"
|
||||
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
|
||||
tone?: "light" | "stage"
|
||||
className?: string
|
||||
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
|
||||
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 (
|
||||
<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}
|
||||
</Typography>
|
||||
<Typography variant="title">{title}</Typography>
|
||||
<Typography variant="title" className={cn(onStage && "text-on-stage")}>
|
||||
{title}
|
||||
</Typography>
|
||||
{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}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@ -6,6 +6,8 @@ const sectionBg = {
|
||||
white: "bg-white",
|
||||
surface: "bg-surface",
|
||||
dark: "bg-ink text-white",
|
||||
/** 히어로와 같은 무대. 어두운 섹션은 이걸 쓴다 — 페이지 안에 어둠이 두 종류면 따로 논다. */
|
||||
stage: "stage-bg text-on-stage",
|
||||
}
|
||||
|
||||
const sectionWidth = {
|
||||
@ -37,7 +39,8 @@ function Section({
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{decor}
|
||||
|
||||
@ -56,7 +56,7 @@ function SlateLeaf({ leaf }: { leaf: SlateText }) {
|
||||
children = <em className="italic">{children}</em>
|
||||
}
|
||||
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>
|
||||
|
||||
@ -3,28 +3,56 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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("", {
|
||||
variants: {
|
||||
variant: {
|
||||
// 히어로 h1
|
||||
display: "text-3xl sm:text-4xl md:text-[50px] font-black tracking-tight leading-[1.2] text-ink break-keep",
|
||||
// 라이트 섹션 h1
|
||||
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
|
||||
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",
|
||||
// 섹션 상단 오버라인 라벨
|
||||
eyebrow: "text-sm font-semibold text-primary tracking-wider uppercase",
|
||||
cardTitle: "text-[20px] font-bold tracking-[-0.028em] leading-[1.3] text-ink break-keep",
|
||||
/* 섹션 상단 라벨. 영문 전용 — 한글 내용은 title 이 맡고, 여기는 조판 요소다.
|
||||
작은 대문자 산세리프 라벨은 어느 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",
|
||||
body: "text-[15px] text-ink-soft font-medium leading-relaxed break-keep",
|
||||
small: "text-sm text-ink-soft font-medium leading-relaxed break-keep",
|
||||
caption: "text-xs text-ink-muted font-semibold leading-relaxed break-keep",
|
||||
// 11px 트래킹 대문자 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
|
||||
micro: "text-[11px] font-bold text-ink-muted uppercase tracking-wider",
|
||||
lead: "text-[16px] sm:text-[17px] text-ink-soft font-normal leading-[1.6] break-keep",
|
||||
body: "text-[15px] text-ink-soft font-normal leading-[1.6] break-keep",
|
||||
small: "text-[14px] text-ink-soft font-normal leading-[1.6] break-keep",
|
||||
caption: "text-[12.5px] text-ink-muted font-normal leading-[1.55] break-keep",
|
||||
// 메타 라벨 (단가 패널·푸터 컬럼 제목 등)
|
||||
micro: "text-[11px] font-semibold text-ink-muted tracking-normal",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "body" },
|
||||
@ -32,6 +60,7 @@ const typographyVariants = cva("", {
|
||||
|
||||
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
|
||||
display: "h1",
|
||||
stageDisplay: "h1",
|
||||
title: "h2",
|
||||
heading: "h3",
|
||||
cardTitle: "h3",
|
||||
|
||||
177
landing/app/lib/negotiation-sim.ts
Normal file
177
landing/app/lib/negotiation-sim.ts
Normal 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 }
|
||||
}
|
||||
@ -8,6 +8,9 @@ export const links: Route.LinksFunction = () => [
|
||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
||||
// 본문 서체 프리로드 — 프리렌더된 첫 화면의 FOUT 최소화
|
||||
{ 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 }) {
|
||||
|
||||
@ -5,14 +5,14 @@ import { Faq } from "@/components/sections/faq";
|
||||
import { FinalCTA } from "@/components/sections/final-cta";
|
||||
import { Footer } from "@/components/sections/footer";
|
||||
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 { NegotiationConsole } from "@/components/sections/negotiation-console";
|
||||
import { NegotiationDemo } from "@/components/sections/negotiation-demo";
|
||||
import { Reinforcement } from "@/components/sections/reinforcement";
|
||||
|
||||
const TITLE = "negotium — AI 구매 협상 자동화";
|
||||
const DESCRIPTION =
|
||||
"가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션.";
|
||||
"품목·목표가·마감일만 정하면 협상 에이전트가 협력사마다 1:1로 단가를 조율하고 낙찰까지 판정합니다. 협상 기록이 쌓일수록 조건이 좋아지는 B2B 구매 협상 자동화 솔루션.";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
@ -30,8 +30,8 @@ export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-ink font-sans antialiased selection:bg-primary/10 selection:text-primary">
|
||||
<Header />
|
||||
<HeroNeumorphic />
|
||||
<NegotiationConsole />
|
||||
<HeroDataFlow />
|
||||
<NegotiationDemo />
|
||||
<HowItWorksDemo />
|
||||
<Reinforcement />
|
||||
<CoreValues />
|
||||
|
||||
BIN
landing/public/fonts/PlayfairDisplay-Italic.woff2
Normal file
BIN
landing/public/fonts/PlayfairDisplay-Italic.woff2
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user