Merge remote-tracking branch 'origin/feat/landing-v2' into feature/landing
This commit is contained in:
commit
dce2c42d74
1
landing/.gitignore
vendored
1
landing/.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
.react-router
|
||||
.vercel
|
||||
|
||||
114
landing/api/lead.ts
Normal file
114
landing/api/lead.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import type { VercelRequest, VercelResponse } from "@vercel/node"
|
||||
|
||||
/*
|
||||
* 리드 수집 엔드포인트 (Vercel 서버리스 함수).
|
||||
*
|
||||
* 랜딩은 ssr:false 정적 빌드라 서버 라우트가 없다. 그래서 /api 디렉터리의 함수로 받는다.
|
||||
* 클라이언트는 같은 오리진으로 쏘므로 엔드포인트 환경변수도, CORS 설정도 필요 없다.
|
||||
*
|
||||
* ── 리드를 잃지 않는 것이 이 파일의 유일한 책임이다 ──────────────────────────
|
||||
* 기존 문의 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않았다. 화면은
|
||||
* "접수되었습니다"인데 영업이 받을 리드는 없고, 아무도 그 사실을 몰랐다.
|
||||
*
|
||||
* 그래서 순서를 이렇게 잡는다.
|
||||
* 1) 먼저 구조화된 로그를 남긴다. 웹훅이 죽어도 Vercel 로그에 리드가 남는다.
|
||||
* 2) LEAD_WEBHOOK_URL 이 있으면 그리로 전달한다(Slack Incoming Webhook·Zapier·자체 API 모두 POST 를 받는다).
|
||||
* 3) 웹훅 전달이 실패해도 200 을 준다 — 1) 에서 이미 리드를 확보했으므로 방문자에게
|
||||
* 다시 입력하라고 할 이유가 없다. 대신 실패는 로그에 크게 남긴다.
|
||||
*
|
||||
* 로그는 CRM 이 아니라 최후의 그물이다. LEAD_WEBHOOK_URL 은 반드시 설정해야 한다.
|
||||
*/
|
||||
|
||||
const MAX_FIELD = 500
|
||||
|
||||
type LeadBody = {
|
||||
source?: string
|
||||
name?: string
|
||||
email?: string
|
||||
company?: string
|
||||
phone?: string
|
||||
message?: string
|
||||
/* 봇 함정. 사람 눈에 안 보이는 필드라 값이 차 있으면 자동 제출이다. */
|
||||
website?: string
|
||||
}
|
||||
|
||||
const clean = (v: unknown) => (typeof v === "string" ? v.trim().slice(0, MAX_FIELD) : "")
|
||||
|
||||
/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
|
||||
const emailLike = (v: string) => v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v)
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST")
|
||||
return res.status(405).json({ ok: false, error: "method_not_allowed" })
|
||||
}
|
||||
|
||||
const body = (typeof req.body === "string" ? safeParse(req.body) : req.body) as LeadBody | null
|
||||
if (!body) return res.status(400).json({ ok: false, error: "invalid_json" })
|
||||
|
||||
// 봇은 조용히 돌려보낸다. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다.
|
||||
if (clean(body.website)) return res.status(200).json({ ok: true })
|
||||
|
||||
const lead = {
|
||||
source: clean(body.source) || "unknown",
|
||||
name: clean(body.name),
|
||||
email: clean(body.email),
|
||||
company: clean(body.company),
|
||||
phone: clean(body.phone),
|
||||
message: clean(body.message),
|
||||
submittedAt: new Date().toISOString(),
|
||||
userAgent: clean(req.headers["user-agent"]),
|
||||
}
|
||||
|
||||
if (!lead.name || !emailLike(lead.email)) {
|
||||
return res.status(400).json({ ok: false, error: "invalid_input" })
|
||||
}
|
||||
|
||||
// 1) 무슨 일이 있어도 먼저 남긴다.
|
||||
console.log("[lead]", JSON.stringify(lead))
|
||||
|
||||
// 2) 사람이 실제로 보는 곳으로 전달.
|
||||
const webhook = process.env.LEAD_WEBHOOK_URL
|
||||
if (!webhook) {
|
||||
console.warn("[lead] LEAD_WEBHOOK_URL 미설정 — 리드가 로그에만 남습니다. 웹훅을 설정하세요.")
|
||||
return res.status(200).json({ ok: true, delivered: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(webhook, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// Slack Incoming Webhook 은 text 를 읽고, 나머지 수신처는 보통 원본 필드를 읽는다.
|
||||
// 둘 다 담아 보내면 수신처를 바꿀 때 이 파일을 고칠 일이 없다.
|
||||
body: JSON.stringify({ text: summarize(lead), ...lead }),
|
||||
})
|
||||
if (!upstream.ok) {
|
||||
console.error("[lead] 웹훅 전달 실패", upstream.status, JSON.stringify(lead))
|
||||
return res.status(200).json({ ok: true, delivered: false })
|
||||
}
|
||||
return res.status(200).json({ ok: true, delivered: true })
|
||||
} catch (e) {
|
||||
console.error("[lead] 웹훅 예외", e, JSON.stringify(lead))
|
||||
return res.status(200).json({ ok: true, delivered: false })
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse(s: string) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(l: { source: string; name: string; email: string; company: string; phone: string; message: string }) {
|
||||
const rows = [
|
||||
`*새 리드* (${l.source})`,
|
||||
`이름: ${l.name}`,
|
||||
`이메일: ${l.email}`,
|
||||
l.company && `회사: ${l.company}`,
|
||||
l.phone && `연락처: ${l.phone}`,
|
||||
l.message && `내용: ${l.message}`,
|
||||
].filter(Boolean)
|
||||
return rows.join("\n")
|
||||
}
|
||||
@ -10,38 +10,112 @@
|
||||
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%);
|
||||
}
|
||||
|
||||
/* 드래그 선택 색.
|
||||
루트에 걸린 selection:text-primary 는 라이트 본문 기준이다. 그 값(#0101F3)을 다크 무대
|
||||
위에 얹으면 명암비가 2.0:1 이라 — 위 --color-primary-on-stage 주석이 경고하는 바로 그
|
||||
상황 — 무대 섹션에서 텍스트를 드래그하면 글자가 사라진다.
|
||||
무대 안에서는 무대용 액센트로 갈아끼운다. 라이트 본문은 기존 그대로. */
|
||||
.stage-bg ::selection,
|
||||
[data-stage-hero] ::selection {
|
||||
background-color: color-mix(in srgb, var(--color-primary-on-stage) 30%, transparent);
|
||||
color: var(--color-on-stage);
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
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"
|
||||
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { useFadeUp } from "@/lib/motion"
|
||||
|
||||
@ -44,28 +44,31 @@ export function Comparison() {
|
||||
|
||||
return (
|
||||
<Section id="comparison">
|
||||
<motion.div {...fadeUp} className="mb-12">
|
||||
<motion.div {...fadeUp}>
|
||||
<SectionHeading
|
||||
align="center"
|
||||
eyebrow="WHY NEGOTIUM"
|
||||
eyebrow="Before & After"
|
||||
title="협상 방식을 바꾸면, 무엇이 달라질까요"
|
||||
description="기존 가격 협상의 문제는 사람이 아니라 구조입니다."
|
||||
descriptionClassName="mt-4 text-[17px] max-w-2xl"
|
||||
descriptionClassName="max-w-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법 */}
|
||||
<motion.div {...toggleFadeUp} className="flex justify-center mb-14">
|
||||
<div className="p-1 bg-fill rounded-[14px] inline-flex gap-1">
|
||||
{/* 모드 토글 — 이용 가이드 탭과 같은 세그먼트 문법.
|
||||
아래 간격(CONTROL_GAP)은 머리 간격보다 좁다. 토글은 자기가 조종하는
|
||||
카드들과 한 덩어리로 읽혀야지, 제목에 붙으면 무엇을 바꾸는 스위치인지 흐려진다. */}
|
||||
<motion.div {...toggleFadeUp} className={`flex justify-center ${CONTROL_GAP}`}>
|
||||
{/* 액티브를 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>
|
||||
))}
|
||||
@ -85,7 +88,7 @@ type Mode = "before" | "after"
|
||||
|
||||
const MODES: { key: Mode; label: string }[] = [
|
||||
{ key: "before", label: "기존 방식" },
|
||||
{ key: "after", label: "negotium 도입 후" },
|
||||
{ key: "after", label: "네고시움 도입 후" },
|
||||
]
|
||||
|
||||
type CompareItem = { label: string; icon: LucideIcon; before: string; after: string }
|
||||
@ -97,13 +100,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 +159,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,7 @@ 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"
|
||||
import { submitLead } from "@/lib/lead"
|
||||
|
||||
const EMPTY_FORM = {
|
||||
companyName: '',
|
||||
@ -18,19 +18,33 @@ const EMPTY_FORM = {
|
||||
message: '',
|
||||
}
|
||||
|
||||
/** 도입 문의 폼. 백엔드 미연결 — 제출은 데모 처리(1.2초 후 성공 화면). */
|
||||
/*
|
||||
* 상담 신청 폼 — 고관여 경로. 저관여 리드 캡처는 데모 요청 모달이 맡는다.
|
||||
*
|
||||
* 예전에는 백엔드가 없어서 1.2초 뒤 성공 화면만 띄웠다. 화면은 "접수되었습니다"인데
|
||||
* 영업이 받을 리드는 존재하지 않았고, 아무도 그 사실을 몰랐다. 지금은 데모 요청과
|
||||
* 같은 서버리스 함수(api/lead.ts)로 보내고, 서버가 리드를 확보했을 때만 성공을 띄운다.
|
||||
*/
|
||||
export function Contact() {
|
||||
const [formData, setFormData] = useState(EMPTY_FORM)
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>('idle')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formData.companyName || !formData.contactName || !formData.email || !formData.phone) {
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('submitting')
|
||||
setTimeout(() => setStatus('success'), 1200)
|
||||
const result = await submitLead({
|
||||
source: 'contact',
|
||||
name: formData.contactName,
|
||||
email: formData.email,
|
||||
company: formData.companyName,
|
||||
phone: formData.phone,
|
||||
message: formData.message,
|
||||
})
|
||||
setStatus(result.ok ? 'success' : 'error')
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
@ -41,22 +55,12 @@ 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 +74,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 +102,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 +115,7 @@ export function Contact() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="연락처" icon={Phone} required>
|
||||
<FormField label="연락처" required>
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
@ -128,30 +132,34 @@ export function Contact() {
|
||||
<Textarea
|
||||
name="message"
|
||||
rows={4}
|
||||
placeholder="현재 겪고 계신 구매 조율 상의 번거로움이나, 자동화를 원하시는 구체적인 부자재 품목 정보를 남겨주시면 더욱 맞춤화된 상담이 가능합니다."
|
||||
placeholder="협상 자동화를 검토 중인 품목이나 현재 겪는 어려움을 적어주세요."
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 대체 연락처(이메일 등)를 넣으려면 실제 영업 주소를 받아서 채워야 한다.
|
||||
임의의 주소를 공개 랜딩에 박아둘 수는 없다. */}
|
||||
{status === 'error' && (
|
||||
<p role="alert" className="text-[13px] text-ink-soft leading-[1.6] break-keep">
|
||||
지금은 접수가 어렵습니다. 잠시 후 다시 시도해 주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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 +170,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 +197,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>
|
||||
|
||||
@ -12,13 +12,12 @@ export function CoreValues() {
|
||||
|
||||
return (
|
||||
<Section id="core-values">
|
||||
<motion.div {...fadeUp} className="mb-24">
|
||||
<motion.div {...fadeUp}>
|
||||
<SectionHeading
|
||||
align="center"
|
||||
eyebrow="BEYOND EXPECTED VALUE"
|
||||
eyebrow="Beyond Price"
|
||||
title="가격 그 이상의 이점"
|
||||
description="절감액은 시작일 뿐 — 관계, 투명성, 그리고 사람의 시간까지 지킵니다."
|
||||
descriptionClassName="mt-4 text-[17px]"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@ -37,7 +36,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 +55,7 @@ const VALUES: Value[] = [
|
||||
icon: Users,
|
||||
title: "파트너사 관계 수호",
|
||||
description:
|
||||
"단가를 깎는 악역과 감정 소모는 봇이 맡습니다. 담당자는 협력사와의 신뢰와 동반 성장, 큰 틀의 파트너십에만 집중하세요.",
|
||||
"단가 조율은 에이전트가 정해진 기준으로 반복합니다. 담당자는 협력사와의 신뢰와 동반 성장에 집중할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
@ -68,7 +67,7 @@ const VALUES: Value[] = [
|
||||
icon: Briefcase,
|
||||
title: "핵심 전략에만 집중",
|
||||
description:
|
||||
"이메일·메신저로 반복되던 흥정 수작업은 봇이 전담합니다. 인력은 공급망 위기 대처, 우량 공급처 발굴 같은 전략 업무와 대형 거래에 투입하세요.",
|
||||
"이메일·메신저로 오가던 단가 협의는 에이전트가 전담합니다. 인력은 공급망 위기 대응과 신규 공급처 발굴에 투입하세요.",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
|
||||
@ -14,13 +14,12 @@ export function Faq() {
|
||||
|
||||
return (
|
||||
<Section id="faq" width="sm">
|
||||
<motion.div {...fadeUp} className="mb-24">
|
||||
<motion.div {...fadeUp}>
|
||||
<SectionHeading
|
||||
align="center"
|
||||
eyebrow="FAQ"
|
||||
title="자주 묻는 질문"
|
||||
description="도입 검토에서 가장 많이 받는 질문들입니다."
|
||||
descriptionClassName="mt-4 text-[17px]"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@ -55,7 +54,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 +95,14 @@ function FaqItem({
|
||||
|
||||
const FAQS: FaqEntry[] = [
|
||||
{
|
||||
question: "공급사가 봇과의 협상을 싫어하지 않을까요?",
|
||||
question: "협력사가 에이전트와의 협상을 꺼리지 않을까요?",
|
||||
answer:
|
||||
"오히려 반대입니다. 이 거래들 대부분은 지금껏 협상 테이블에 오르지도 못하던 건입니다. 봇은 24시간 원하는 시간에, 압박 없이, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%에 이릅니다.",
|
||||
"이 건들 대부분은 지금껏 협상 테이블에 오르지도 못하던 거래입니다. 에이전트는 협력사가 편한 시간에, 늘 같은 기준으로 응대합니다. 글로벌 동종 서비스의 공급사 만족도는 82%로 보고됩니다.",
|
||||
},
|
||||
{
|
||||
question: "봇이 우리 기준을 벗어나 합의해 버리면요?",
|
||||
question: "에이전트가 우리 기준을 벗어나 합의해 버리면요?",
|
||||
answer:
|
||||
"그럴 수 없습니다. 봇은 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
|
||||
"그럴 수 없습니다. 에이전트는 견적을 만들 때 정한 목표가와 낙찰 기준 밖으로 나가지 않고, 최종 낙찰 규칙도 사용자가 정합니다.",
|
||||
},
|
||||
{
|
||||
question: "기존 시스템과 연동되나요?",
|
||||
|
||||
@ -2,58 +2,71 @@ import { motion } from "motion/react"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { NegotiationReplay } from "@/components/ui/negotiation-replay"
|
||||
import { Section } from "@/components/ui/section"
|
||||
import { CTA_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||
import { useFadeUp } from "@/lib/motion"
|
||||
|
||||
/** 최종 CTA — 다크 몰입 섹션. */
|
||||
/**
|
||||
* 최종 CTA — 히어로와 같은 다크 무대로 페이지를 닫는다.
|
||||
*
|
||||
* 이 섹션은 원래 디자인 시스템을 우회하고 있었다: bg-ink(무대 색이 아님), font-extrabold,
|
||||
* 알약 배지, blur(120px) 글로우 블롭, 다크 배경 위 라이트 토큰(text-ink-muted).
|
||||
* 전부 토큰으로 돌렸다. 글로우 블롭은 AI 가 만든 히어로의 대표 장식이라 걷어냈다.
|
||||
*
|
||||
* 섹션 껍데기와 머리도 손으로 짜던 걸 Section/SectionHeading 으로 돌렸다.
|
||||
* 우회하는 동안 아이브로우 간격이 여기만 20px 이었다(다른 섹션은 12px).
|
||||
*
|
||||
* 전환을 요청하는 자리에 결과 화면을 같이 둔다. 이용 가이드는 "실제 화면으로 보여드립니다"
|
||||
* 라고 약속해서 재구성 목업을 넣을 수 없지만, 여기는 그 약속이 없어 마케팅 컴포짓이 맞는 자리다.
|
||||
*/
|
||||
export function FinalCTA() {
|
||||
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" />
|
||||
const fadeUp = useFadeUp()
|
||||
const mediaFadeUp = useFadeUp(0.12)
|
||||
|
||||
<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>
|
||||
return (
|
||||
/* 영상이 붙으면서 width 를 md(max-w-4xl) → lg(max-w-5xl) 로 넓혔다.
|
||||
세로 목업과 카피를 4xl 안에 2단으로 밀어넣으면 양쪽 다 좁아진다. */
|
||||
<Section bg="stage" width="lg">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center">
|
||||
<motion.div {...fadeUp} className="lg:col-span-7 text-center lg:text-left">
|
||||
<SectionHeading
|
||||
align="left"
|
||||
className="text-center lg:text-left"
|
||||
tone="stage"
|
||||
size="display"
|
||||
gap="none"
|
||||
eyebrow="Start Today"
|
||||
/* 닫는 CTA 라 히어로(74px)보다는 낮추고 섹션 제목(64px)보다는 세운다.
|
||||
2단이 되면서 한 줄이 짧아져 lg 부터 한 단계 더 줄인다. */
|
||||
titleClassName="text-[32px] sm:text-[44px] md:text-[56px] lg:text-[48px]"
|
||||
title={
|
||||
<>
|
||||
<span className="text-white">지금도 구매 예산은</span>
|
||||
<br />
|
||||
<span className="text-on-stage-soft/60">조용히 새고 있을지 모릅니다</span>
|
||||
</>
|
||||
}
|
||||
description="협상 테이블에 오르지 못하고 그냥 넘어가던 건들이 있습니다. 네고시움 에이전트가 정해진 기준 안에서 그 건들을 대신 조율합니다."
|
||||
descriptionClassName="max-w-xl text-on-stage-soft/75 mx-auto lg:mx-0"
|
||||
/>
|
||||
|
||||
<div className={`${CTA_GAP} flex justify-center lg:justify-start`}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* 결과 화면. 예전엔 negotiation_annotated_3d.mp4 였는데 컴포넌트로 바꿨다 —
|
||||
영상은 화자("아이마켓 구매 MD")와 색이 픽셀에 구워져 있어 둘 다 못 고쳤다.
|
||||
다크 무대에 네이티브로 그리니 밝은 영상이 구멍처럼 뜨던 문제도 같이 사라져서,
|
||||
카드로 감싸고 그림자로 띄우던 처리가 더는 필요 없다. */}
|
||||
<motion.div {...mediaFadeUp} className="lg:col-span-5">
|
||||
<NegotiationReplay className="mx-auto max-w-[360px] lg:max-w-none" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</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 구매 협상 자동화 솔루션입니다.
|
||||
네고시움은 협상 에이전트가 공급사와 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>
|
||||
|
||||
133
landing/app/components/sections/hero-dataflow.tsx
Normal file
133
landing/app/components/sections/hero-dataflow.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useState } from "react"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DemoRequestModal } from "@/components/ui/demo-request-modal"
|
||||
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
|
||||
/**
|
||||
* 다크 무대 히어로 — 배경 루프 영상.
|
||||
*
|
||||
* 예전에는 캔버스가 negotium 파이프라인을 직접 그렸고(입자 240개·6단계 서사·무리 라벨),
|
||||
* 배경이 곧 설명이었다. 지금은 배경을 배경으로만 쓴다 — 설명은 카피와 아래 섹션이 맡는다.
|
||||
* 그래서 무리 라벨(CLUSTER_ANCHORS)도 함께 걷어냈다. 좌표 소스가 사라진 라벨은
|
||||
* 화면비가 바뀔 때마다 어긋나기만 하고 아무것도 설명하지 못한다.
|
||||
*
|
||||
* 배경이 의미를 지지 않으므로 카피 가독성이 1순위다. 그래서:
|
||||
* - 영상 위에 스크림을 깐다. 예전 캔버스는 하단 밴드에만 입자가 있어 상단이 비었지만,
|
||||
* 이 영상은 화면 전체에 밝은 요소가 흩어져 흰 글자가 묻힌다.
|
||||
* - 카피는 화면 중앙 정렬. 밴드 개념이 없으니 상하로 나눌 이유도 없다.
|
||||
*/
|
||||
export function HeroDataFlow() {
|
||||
const [demoOpen, setDemoOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<section
|
||||
data-stage-hero
|
||||
className="stage-bg relative min-h-screen text-on-stage flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* 영상은 가로 화면에서만 튼다.
|
||||
16:9 소스를 세로 화면에 object-cover 로 깔면 가로가 62% 잘리고 남은 38% 가
|
||||
1.7배로 확대된다(848x1220 실측) — 데이터 입자가 거대한 보케로 뭉개져서
|
||||
배경이 아니라 노이즈가 된다. 캔버스는 뷰포트에 맞춰 그렸으니 없던 문제다.
|
||||
세로에서는 무대 그라데이션(stage-bg)만 남긴다. 그게 브랜드 배경이라 빈 화면이 아니다.
|
||||
prefers-reduced-motion 에서도 같은 이유로 영상을 감춘다 — 포스터가 뒤를 받친다. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 hidden landscape:block motion-reduce:hidden bg-cover bg-center"
|
||||
style={{ backgroundImage: "url(/gifs/hero_loop.jpg)" }}
|
||||
>
|
||||
<video
|
||||
className="w-full h-full object-cover"
|
||||
src="/gifs/hero_loop.mp4"
|
||||
poster="/gifs/hero_loop.jpg"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카피 가독성용 스크림.
|
||||
색은 하나(stage-deep)로 두고 알파만 움직인다 — 중간 stop 에서 색이 바뀌면
|
||||
그 지점이 가로선으로 읽힌다.
|
||||
|
||||
알파는 90/60/95 였다가 낮췄다. 그 값은 헤드라인 둘째 줄이 회색(/60)이던 시절
|
||||
그 글자를 살리려고 깐 것인데, 가장 옅은 지점에서도 영상이 40% 만 남아서
|
||||
배경 전체가 탁해졌다. 헤드라인이 흰색이 된 지금은 이만큼 덮을 이유가 없다.
|
||||
구간별로 필요한 만큼만 준다 — 위는 헤더 nav(작은 회색 글자), 가운데는 흰
|
||||
헤드라인이라 가장 옅게, 아래는 다음 섹션으로 넘어가는 암전(h-32)이 따로 있다. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 pointer-events-none bg-linear-to-b from-stage-deep/50 via-transparent to-stage-deep/45"
|
||||
/>
|
||||
|
||||
{/* 카피 뒤만 국소적으로 누른다.
|
||||
전면을 균일하게 덮으면 글자는 살지만 영상이 통째로 탁해진다. 반대로 다 걷으면
|
||||
헤드라인(흰색 74px)은 버텨도 리드 문단(17px)이 밝은 입자 위에서 읽히지 않는다.
|
||||
둘 다 만족하는 유일한 방법은 덮는 범위를 글자가 있는 자리로 좁히는 것이다.
|
||||
가장자리는 영상이 그대로 보이고 가운데만 어두워진다. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(58% 42% at 50% 45%," +
|
||||
" color-mix(in srgb, var(--color-stage-deep) 85%, transparent) 0%," +
|
||||
" color-mix(in srgb, var(--color-stage-deep) 55%, transparent) 45%," +
|
||||
" transparent 72%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 아래 라이트 섹션과의 경계를 부드럽게 */}
|
||||
<div aria-hidden className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
|
||||
|
||||
{/* pt-[104px] 는 fixed 헤더 회피용 바닥. 그 아래 남은 공간에서 수직 중앙 정렬한다. */}
|
||||
<div className="relative z-10 w-full flex-1 pt-[104px] pb-16 flex items-center">
|
||||
<div className="max-w-4xl mx-auto px-6 w-full text-center">
|
||||
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
|
||||
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
|
||||
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 영상이 맡는다. */}
|
||||
{/* 여기는 2톤을 쓰지 않는다. 예전 캔버스 배경은 하단 밴드에만 입자가 있어 헤드라인
|
||||
뒤가 거의 단색이었고, 그때는 투명도 60% 로 낮춘 둘째 줄도 읽혔다. 배경이 영상으로
|
||||
바뀌면서 밝은 요소가 글자 뒤로 지나가 그 대비가 무너졌다 — 배경이 움직이는 위에서는
|
||||
가독성이 조판 장치보다 우선한다. 2톤은 최종 CTA 에 남아 있다(그쪽은 배경이 정적). */}
|
||||
<Typography variant="stageDisplay">
|
||||
<span className="text-white">협력사 전원과 1:1로,</span>
|
||||
<br />
|
||||
<span className="text-white">동시에 협상합니다.</span>
|
||||
</Typography>
|
||||
|
||||
{/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은
|
||||
같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */}
|
||||
<p className={`${DISPLAY_LEAD_GAP} text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto`}>
|
||||
품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에
|
||||
최저 투찰가로 낙찰까지 판정합니다.
|
||||
</p>
|
||||
|
||||
{/* 2차 CTA 는 상담 신청(#contact-section)과 목적지가 겹쳐 사실상 같은 버튼이었다.
|
||||
데모 경로로 갈라서, 고관여 폼(상담 신청) 앞단에 저관여 리드 캡처를 둔다.
|
||||
|
||||
문구는 "클릭하면 무슨 일이 일어나는가"에 맞춘다.
|
||||
협상 예시 체험 — 페이지 안에서 바로 조작한다. 고정 데이터라 "예시"를 밝히되,
|
||||
도착 섹션이 "직접 경험해 보세요"·"해보기" 라 "보기"로 낮추면 어긋난다.
|
||||
실제 데모 받기 — 누르면 폼이 뜨고 이메일로 온다. "체험"이라 쓰면 눌렀을 때
|
||||
바로 만질 줄 알았다가 입력을 요구받아 낚인 느낌이 된다. */}
|
||||
<div className={`${CTA_GAP} 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 type="button" variant="stageGhost" size="stageRound" onClick={() => setDemoOpen(true)}>
|
||||
실제 데모 받기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DemoRequestModal open={demoOpen} onClose={() => setDemoOpen(false)} />
|
||||
</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,10 +1,10 @@
|
||||
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"
|
||||
import { SectionHeading } from "@/components/ui/section-heading"
|
||||
import { CONTROL_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
|
||||
/** 3단계 이용 가이드 — 탭 전환식 GIF 시연 (데스크톱/모바일 목업). */
|
||||
@ -17,21 +17,20 @@ export function HowItWorksDemo() {
|
||||
<Section id="how-it-works-demo" width="lg" bordered className="overflow-hidden">
|
||||
<SectionHeading
|
||||
align="center"
|
||||
className="mb-20"
|
||||
eyebrow="SERVICE DEMONSTRATION"
|
||||
eyebrow="How it Works"
|
||||
title="견적 생성부터 낙찰까지 한눈에 보기"
|
||||
description="복잡해 보이는 구매 과정이 어떻게 자동화되는지 실제 작동 화면(GIF)을 통해 쉽고 직관적으로 확인해 보세요."
|
||||
descriptionClassName="text-[17px] max-w-2xl"
|
||||
description="견적을 열고 협상이 끝나기까지, 실제 화면으로 보여드립니다."
|
||||
descriptionClassName="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={`flex justify-center ${CONTROL_GAP}`}>
|
||||
<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 +53,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 +157,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 +181,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>인터넷 최저가(LPS)</b> 데이터를 상시 반영해{" "}
|
||||
<b className="text-primary">시장 최저가를 기준으로 시작합니다.</b>
|
||||
</>,
|
||||
<>
|
||||
시중 최저가와 매입 이력, 목표 마진율을 종합해 — 사내 마진은 지키면서 공급사가 받아들일 수 있는{" "}
|
||||
@ -211,7 +209,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 +218,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 +232,18 @@ const DEMO_TABS: DemoTab[] = [
|
||||
각 파트너사 담당자는 초대 메일로 <b>비대면 협상 모바일 포털</b>에 접속해, 언제든 단가를 조율합니다.
|
||||
</>,
|
||||
<>
|
||||
단순 마진 깎기가 아닙니다. 봇은 준비된 협상 카드를 상황에 맞게 꺼내, 파트너사의 자발적인 마진 타협을 부드럽게
|
||||
단순히 깎는 게 아닙니다. 에이전트는 준비된 협상 카드를 상황에 맞게 꺼내, 협력사가 받아들일 조건을
|
||||
이끌어냅니다.
|
||||
</>,
|
||||
],
|
||||
checks: ["1:1 비대면 협상 포털 제공", "불필요한 실랑이를 예방하여 파트너십 보호"],
|
||||
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
|
||||
mockup: "phone",
|
||||
/* mp4 를 붙이기 전까지 이 탭만 1.7MB GIF(390x780)를 내려받고 있었다. 같은 내용의
|
||||
mp4 가 저장소에 있는데 참조가 빠져 있었다 — 786K, 540x1080 으로 더 가볍고 더 크다.
|
||||
(원본 캡쳐가 좌우로 잘려 문장이 끊기는 문제는 남아 있다. 재촬영이 필요하다.) */
|
||||
gif: "/gifs/negotiation.gif",
|
||||
gifAlt: "AI 모바일 자동 협상 시연",
|
||||
video: "/gifs/negotiation.mp4",
|
||||
gifAlt: "협력사 포털 모바일 협상 화면",
|
||||
fallback: {
|
||||
icon: Smartphone,
|
||||
iconWrapClassName: "w-12 h-12 bg-positive-soft border border-positive/15 rounded-full text-positive",
|
||||
@ -254,7 +256,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 +282,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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
466
landing/app/components/sections/negotiation-demo.tsx
Normal file
466
landing/app/components/sections/negotiation-demo.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { ArrowRight, RotateCcw } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 {
|
||||
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)
|
||||
/* 입력란의 원문. offer 와 따로 두는 이유는 타이핑 도중 상태다 — 숫자 하나로 묶으면
|
||||
"1" 을 치는 순간 범위 밖이라 튕겨서 사용자와 싸우게 된다. 확정은 blur/Enter 에서 한다. */
|
||||
const [offerText, setOfferText] = useState(won(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)
|
||||
const start = nextRole === "seller" ? nextItem.listPrice : nextItem.marketLow
|
||||
setOffer(start)
|
||||
setOfferText(won(start))
|
||||
}
|
||||
|
||||
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)
|
||||
setOfferText(won(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
|
||||
|
||||
/* 협상이 시작된 뒤 구매자는 목표가를 못 바꾼다 — 목표가를 정하고 위임하는 게 제품의 전제다.
|
||||
공급사는 카드가 남아 있는 한 계속 제시할 수 있다. */
|
||||
const inputLocked = role === "buyer" && turns.length > 0
|
||||
|
||||
/* 추천가는 시뮬레이터가 실제로 쓰는 수치에서 가져온다. 지어낸 숫자를 추천이라 부르면
|
||||
사용자가 그대로 넣었을 때 결과가 설명되지 않는다. */
|
||||
const recommended = role === "seller" ? item.marketLow : item.anchor
|
||||
const recommendReason =
|
||||
role === "seller"
|
||||
? `같은 사양 시장 최저가 선입니다. 여기서부터 에이전트가 근거를 들고 응수합니다.`
|
||||
: `결렬 없이 도달 가능한 최저선입니다. 더 낮추면 협상이 성사되지 않습니다.`
|
||||
|
||||
/** 슬라이더·직접입력·추천적용이 모두 거쳐가는 단일 확정 경로. 범위로 당기고 10원 단위로 맞춘다. */
|
||||
const commitOffer = (raw: number) => {
|
||||
if (inputLocked) return
|
||||
const next = Number.isFinite(raw) && raw > 0 ? round10(Math.min(sliderMax, Math.max(sliderMin, raw))) : offer
|
||||
setOffer(next)
|
||||
/* 확정된 값은 콤마를 넣어 보여준다 — 화면의 다른 금액이 전부 won() 이라
|
||||
여기만 1018800 으로 나오면 같은 값으로 안 읽힌다. 타이핑 중에는 숫자만 남긴다. */
|
||||
setOfferText(won(next))
|
||||
}
|
||||
|
||||
return (
|
||||
<Section id="how-it-works" bg="stage" width="lg">
|
||||
{/* 머리는 손으로 짜지 않고 SectionHeading 을 쓴다. 예전엔 여기서 직접 조판하느라
|
||||
아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면
|
||||
같은 관계에 두 번째 값이 생긴다. */}
|
||||
{/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다.
|
||||
자동 줄바꿈에 맡기면 어절이 어색하게 끊긴다.
|
||||
|
||||
예전 카피는 "에이전트는 정해진 기준을 벗어나지 않습니다" 였다. FAQ 2번
|
||||
("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로 끌어올린
|
||||
것이었는데, 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었다.
|
||||
게다가 부정문이라 안 하는 일을 약속했다 — 체험을 권하는 자리에는 맞지 않는다.
|
||||
방어는 FAQ 에 그대로 남아 있으니 여기서는 능력을 말한다. */}
|
||||
<SectionHeading
|
||||
align="center"
|
||||
tone="stage"
|
||||
eyebrow="Try it Yourself"
|
||||
title={
|
||||
<>
|
||||
<span className="text-white">사용할수록 진화하는 에이전틱 협상,</span>
|
||||
<br />
|
||||
<span className="text-on-stage-soft/60">직접 경험해 보세요!</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 }}
|
||||
/* 유리 패널로 띄운다. 컨트롤과 대화 로그가 섹션 배경 위에 그냥 얹혀 있으면
|
||||
무대와 같은 색이라 경계가 없고, 조작할 수 있는 영역인지 읽히지 않는다.
|
||||
재질은 무대 위 2차 CTA 와 같다 — 옅은 흰 채움 + backdrop-blur + 위쪽
|
||||
안쪽 하이라이트 + 아래로 깔리는 그림자. 화면 안에 재질이 두 종류면 따로 논다.
|
||||
테두리는 white/12 로 얕게 둔다. 이건 조작 요소가 아니라 면이라 버튼만큼
|
||||
세울 필요가 없고, 세우면 오히려 카드가 버튼처럼 보인다. */
|
||||
className={
|
||||
"rounded-card border border-white/12 bg-white/[0.06] " +
|
||||
"backdrop-blur-xl backdrop-saturate-150 " +
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),0_24px_60px_-24px_rgba(3,6,15,0.9)] " +
|
||||
"p-6 sm:p-8 lg:p-10"
|
||||
}
|
||||
>
|
||||
<div 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">
|
||||
{/* 에이전트 추천가. 값을 지어내지 않고 시뮬레이터가 실제로 쓰는 수치를 쓴다 —
|
||||
구매자는 anchor(결렬 없이 닿는 최저선, reachable = target >= anchor),
|
||||
공급사는 marketLow(에이전트가 응수할 때 인용하는 값). 추천만 하고
|
||||
확정은 사용자가 한다. 그게 이 데모가 보여줘야 할 관계다. */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-control border border-primary-on-stage/25 bg-primary-on-stage/10 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-semibold text-primary-on-stage mb-0.5">에이전트 추천</div>
|
||||
<div className="text-[13px] text-on-stage-soft/75 break-keep">{recommendReason}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitOffer(recommended)}
|
||||
disabled={inputLocked}
|
||||
className="shrink-0 rounded-control bg-primary-on-stage/20 hover:bg-primary-on-stage/30 px-3 py-2 text-[13px] font-bold tabular-nums text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{won(recommended)}원 적용
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label htmlFor="nego-offer" className="text-[14px] font-medium text-on-stage-soft/85">
|
||||
{role === "seller" ? "제시 단가" : "목표 단가"}
|
||||
</label>
|
||||
{/* 직접 입력. 슬라이더만 두면 정확한 값을 못 넣는다 — 실제 구매 담당자는
|
||||
"얼마까지"라는 숫자를 이미 들고 온다. 타이핑 중에는 clamp 하지 않고
|
||||
blur/Enter 에서 확정한다. 입력 즉시 범위로 당기면 손가락과 싸운다. */}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<input
|
||||
id="nego-offer-text"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={role === "seller" ? "제시 단가 직접 입력" : "목표 단가 직접 입력"}
|
||||
value={offerText}
|
||||
onChange={(e) => setOfferText(e.target.value.replace(/[^\d]/g, ""))}
|
||||
onBlur={() => commitOffer(Number(offerText.replace(/[^\d]/g, "")))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
commitOffer(Number(offerText.replace(/[^\d]/g, "")))
|
||||
}
|
||||
}}
|
||||
disabled={inputLocked}
|
||||
className="w-32 bg-transparent border-0 border-b-2 border-stage-line focus:border-primary-on-stage hover:border-on-stage-muted rounded-none px-0 py-1 text-right text-[18px] font-bold tabular-nums text-white outline-none transition-colors disabled:opacity-40"
|
||||
/>
|
||||
<span className="text-[15px] font-semibold text-on-stage-soft/70">원</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id="nego-offer"
|
||||
type="range"
|
||||
aria-label={role === "seller" ? "제시 단가 조절" : "목표 단가 조절"}
|
||||
min={sliderMin}
|
||||
max={sliderMax}
|
||||
step={10_000}
|
||||
value={Math.min(sliderMax, Math.max(sliderMin, offer))}
|
||||
onChange={(e) => commitOffer(Number(e.target.value))}
|
||||
disabled={inputLocked}
|
||||
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={inputLocked}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<p className="mt-12 text-center text-[13px] text-on-stage-soft/40">
|
||||
실제 판정 규칙(앵커링가·협상 카드 3장·개찰)을 그대로 옮긴 데모입니다. 금액은 예시입니다.
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -2,8 +2,9 @@ import { motion } from "motion/react"
|
||||
import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
|
||||
|
||||
import { Section } from "@/components/ui/section"
|
||||
import { SectionHeading } from "@/components/ui/section-heading"
|
||||
import { HEADING_GAP, SectionHeading } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { OrbitRing } from "@/components/ui/orbit-ring"
|
||||
import { useFadeUp } from "@/lib/motion"
|
||||
|
||||
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
|
||||
@ -11,22 +12,40 @@ export function Reinforcement() {
|
||||
const fadeUp = useFadeUp()
|
||||
|
||||
return (
|
||||
<Section id="reinforcement" bg="surface" bordered className="border-b border-line">
|
||||
<motion.div {...fadeUp} className="mb-20">
|
||||
<SectionHeading
|
||||
className="text-center md:text-left"
|
||||
eyebrow="REINFORCEMENT LEARNING"
|
||||
title="협상할수록, 더 좋은 조건으로"
|
||||
description={
|
||||
<>
|
||||
잘 깎는 노하우는 담당자 머릿속에만 남고, 성과는 그날의 감정과 컨디션에 흔들립니다. negotium은 진행된 모든 거래
|
||||
데이터와 파트너 거절 피드백을 <span className="font-bold text-primary">강화학습 기술</span>에 투입합니다.
|
||||
무턱대고 가격을 후려쳐 상대방의 반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수
|
||||
있는 범위 내에서 최선의 마진율을 뽑아내도록 조율 페이스를 학습합니다.
|
||||
</>
|
||||
}
|
||||
descriptionClassName="mt-8 max-w-3xl"
|
||||
/>
|
||||
<Section id="reinforcement" bg="stage" width="lg">
|
||||
{/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 —
|
||||
"협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */}
|
||||
{/* 머리가 그리드 셀 안에 들어가 있어서 여기만 gap="none" 이다. 그리드 아이템은
|
||||
독립 서식 문맥이라 마진이 상쇄되지 않고, 머리에 mb 를 걸면 items-center 의
|
||||
세로 정렬을 밀어버린다. 섹션 간격은 그리드 래퍼가 대신 진다. */}
|
||||
<motion.div {...fadeUp} className={`grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center ${HEADING_GAP}`}>
|
||||
<div className="lg:col-span-7">
|
||||
<SectionHeading
|
||||
className="text-center lg:text-left"
|
||||
gap="none"
|
||||
tone="stage"
|
||||
eyebrow="Reinforcement Learning"
|
||||
/* 쉼표에서 줄을 명시적으로 끊는다. break-keep + text-balance 에 맡기면
|
||||
두 줄 길이를 맞추려고 "협상할수록, 더 / 좋은 조건으로" 처럼 쉼표를
|
||||
넘어가서 끊어진다 — 조건절과 결과절이 한 줄에 섞여 의미가 흐려진다. */
|
||||
title={
|
||||
<>
|
||||
협상할수록,
|
||||
<br />더 좋은 조건으로
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. 네고시움은 지난 거래 기록과
|
||||
협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
|
||||
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-5 flex justify-center">
|
||||
<OrbitRing className="w-[280px] md:w-[340px] h-auto" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
@ -44,15 +63,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 +87,12 @@ const PILLARS: Pillar[] = [
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
title: "지속적인 전략 최적화",
|
||||
description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.",
|
||||
title: "쓸수록 올라가는 기준선",
|
||||
description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.",
|
||||
},
|
||||
{
|
||||
icon: GitMerge,
|
||||
title: "낙찰 성사율 극대화",
|
||||
description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.",
|
||||
title: "결렬 없는 합의",
|
||||
description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.",
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,158 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { ArrowRight, Clock, ShieldCheck } from "lucide-react"
|
||||
|
||||
import { RangeSlider } from "@/components/ui/range-slider"
|
||||
import { Section } from "@/components/ui/section"
|
||||
import { SectionHeading } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
|
||||
/** 도입 ROI 시뮬레이터 — 예산·협력사 수 슬라이더로 예상 절감액을 즉시 계산. */
|
||||
export function ROISimulator() {
|
||||
// 연간 총 구매 예산(억 원)
|
||||
const [budget, setBudget] = useState(50)
|
||||
// 관리 중인 협력사 수
|
||||
const [suppliers, setSuppliers] = useState(50)
|
||||
|
||||
// 보수적 절감율 2.8%~4.5% — 협력사가 많을수록 병렬 대안이 늘어 상향
|
||||
const savingRate = Math.min(0.045, 0.028 + (suppliers / 300) * 0.015)
|
||||
const estimatedSavingsValue = budget * savingRate
|
||||
|
||||
// 협력사당 왕복 흥정 수작업 절약분을 ~6.5시간으로 잡은 추산
|
||||
const savedHours = Math.round(suppliers * 6.5)
|
||||
|
||||
return (
|
||||
<Section id="benchmarks" width="lg" bordered className="border-b border-line">
|
||||
<SectionHeading
|
||||
align="center"
|
||||
className="mb-24"
|
||||
eyebrow="ROI SIMULATION"
|
||||
title={
|
||||
<>
|
||||
negotium 도입 시 <br />
|
||||
예상 절감액 확인하기
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
아직 실제 도입 실증 사례가 없는 신규 카테고리여도 신뢰할 수 있습니다. <br />
|
||||
연간 구매 예산과 공급사 수만 입력해 보수적인 시뮬레이션 성과를 즉시 시각화해 보세요.
|
||||
</>
|
||||
}
|
||||
descriptionClassName="mt-4 text-[17px] max-w-2xl"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
|
||||
{/* 좌: 입력 슬라이더 */}
|
||||
<div className="lg:col-span-6 space-y-12">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[16px] font-bold text-ink">연간 총 구매 예산</label>
|
||||
<span className="text-lg font-black text-primary font-mono">{formatCurrency(budget)}</span>
|
||||
</div>
|
||||
<Typography variant="caption">원자재, 부자재, 가공비, 운송비 등 협상에 연동할 총 연간 구매 규모입니다.</Typography>
|
||||
<div className="pt-2">
|
||||
<RangeSlider min={5} max={1000} step={5} value={budget} onChange={setBudget} />
|
||||
<div className="flex justify-between text-[11px] text-ink-faint font-bold pt-2">
|
||||
<span>5억 원</span>
|
||||
<span>500억 원</span>
|
||||
<span>1조 원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<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>
|
||||
<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">
|
||||
<span>5개사</span>
|
||||
<span>150개사</span>
|
||||
<span>300개사</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 bg-surface p-5 rounded-2xl 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%
|
||||
절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우: 결과 지표 */}
|
||||
<div className="lg:col-span-6 lg:pl-10 space-y-12">
|
||||
<div className="space-y-3">
|
||||
<Typography variant="micro" className="text-primary block">
|
||||
ESTIMATED ANNUAL SAVINGS
|
||||
</Typography>
|
||||
<h3 className="text-sm font-semibold text-ink-muted">매년 절감 가능한 구매 원가 (보수적 추산)</h3>
|
||||
<div className="text-3xl sm:text-[44px] font-black text-primary font-mono tracking-tight leading-tight pt-1">
|
||||
{formatSavings(estimatedSavingsValue)}
|
||||
</div>
|
||||
<Typography variant="caption" className="text-ink-soft font-medium pt-1">
|
||||
사각지대에 방치되던 꼬리 거래(Tail Spend)의 상시 병렬 협상 및 인터넷 실시간 앵커가 시세 연동을 통해, 기존 수동
|
||||
흥정으로 놓치고 있던 원가 이탈을 완벽히 방어해냅니다.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line pt-8 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-ink-muted" />
|
||||
<span className="text-xs font-bold text-ink-soft">
|
||||
연간 실무 단축 시간: <b className="text-ink font-mono text-sm ml-1">{savedHours.toLocaleString()}시간</b>
|
||||
</span>
|
||||
</div>
|
||||
<Typography variant="caption">
|
||||
메일 전송, 유선 흥정, 엑셀 취합 등에 낭비되던 소모적인 절차가 실시간 링크 전송과 AI 봇의 24시간 자율 대응으로
|
||||
완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line pt-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-ink">예상 도입 ROI 회수 기간</h4>
|
||||
<p className="text-[11px] text-ink-muted font-semibold mt-1">도입 즉시 마진 확보 개시</p>
|
||||
</div>
|
||||
<a
|
||||
href="#contact-section"
|
||||
className="inline-flex items-center gap-2 text-xs font-bold text-primary hover:gap-3 transition-all"
|
||||
>
|
||||
<span>무상 맞춤 검증 신청하기</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 억 원 단위 → "50억 원" / "1조 200억 원" 표기 */
|
||||
function formatCurrency(val: number) {
|
||||
if (val >= 100) {
|
||||
const b = Math.floor(val / 100)
|
||||
const m = val % 100
|
||||
return m > 0 ? `${b}조 ${m}00억 원` : `${b}조 원`
|
||||
}
|
||||
return `${val}억 원`
|
||||
}
|
||||
|
||||
/** 억 원 단위 → "1억 5,500만 원" 표기 */
|
||||
function formatSavings(val: number) {
|
||||
const rawWon = val * 100000000
|
||||
if (rawWon >= 100000000) {
|
||||
const eonPart = Math.floor(rawWon / 100000000)
|
||||
const manPart = Math.floor((rawWon % 100000000) / 10000)
|
||||
if (manPart > 0) {
|
||||
return `${eonPart}억 ${manPart.toLocaleString()}만 원`
|
||||
}
|
||||
return `${eonPart}억 원`
|
||||
}
|
||||
return `${Math.floor(rawWon / 10000).toLocaleString()}만 원`
|
||||
}
|
||||
@ -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,40 @@ 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 — 흰 알약.
|
||||
테두리를 stage-line(#1E2A52)으로 두면 안 된다. 그 색은 무대 바닥(#070E24) 위
|
||||
카드용이라, 방사형 광원부(#16205A)에 앉는 GNB 자리에서는 배경과 대비가 1.09:1 로
|
||||
사라진다. 채움도 없으면 남는 건 흰 글자뿐이라 옆 nav 링크와 구분되지 않는다.
|
||||
그래서 무대색 토큰이 아니라 흰색 알파로 잡는다 — 무대 어디에 놓여도 같이 성립한다.
|
||||
옅은 채움이 테두리보다 "누를 수 있는 것"을 더 강하게 전달하므로 둘 다 준다. */
|
||||
/* 유리(glassmorphism). 배경 영상이 비쳐 보이되 글자는 읽히는 상태를 만든다.
|
||||
구성은 넷이다 — 옅은 흰 채움, 뒤를 흐리는 backdrop-blur, 위쪽 1px 안쪽 하이라이트
|
||||
(유리 모서리에 빛이 걸리는 부분), 아래로 깔리는 부드러운 그림자(떠 있는 느낌).
|
||||
채도를 살짝 올리면(saturate) 뒤가 흐려지며 빠지는 색기가 돌아온다.
|
||||
|
||||
테두리는 white/40 에서 더 못 내린다. /30 으로 낮추면 무대 광원부(#16205A) 대비가
|
||||
2.60:1 로 떨어져 WCAG 1.4.11(UI 경계 3:1)에 미달한다 — 유리 느낌 때문에 버튼이
|
||||
안 보이던 원래 문제로 돌아간다. 투명감은 채움과 blur 로 내고 테두리는 지킨다.
|
||||
|
||||
hover 는 흰색 반전이 아니라 채움을 밝힌다. 유리는 뒤가 비치는 게 정체성이라
|
||||
불투명하게 뒤집으면 그 순간 유리가 아니게 된다. */
|
||||
stageGhost:
|
||||
"bg-white/12 hover:bg-white/25 text-white border border-white/40 hover:border-white/60 " +
|
||||
"backdrop-blur-md backdrop-saturate-150 " +
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.30),0_8px_24px_-10px_rgba(3,6,15,0.7)]",
|
||||
},
|
||||
// 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" },
|
||||
|
||||
230
landing/app/components/ui/demo-request-modal.tsx
Normal file
230
landing/app/components/ui/demo-request-modal.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
|
||||
import { Check, Loader2, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||
import { isEmailLike, submitLead } from "@/lib/lead"
|
||||
|
||||
/*
|
||||
* 데모 요청 모달 — 이름·이메일만 받는 리드 캡처.
|
||||
*
|
||||
* 상담 신청(contact.tsx)은 회사명·담당자·연락처·문의내용까지 받는 고관여 폼이다.
|
||||
* 이건 그 앞단이다. 필드가 늘수록 이탈이 늘기 때문에 두 칸에서 멈춘다 — 영업이
|
||||
* 첫 연락을 하는 데 필요한 최소가 이름과 이메일이고, 나머지는 통화에서 채운다.
|
||||
*
|
||||
* 전체 페이지로 보내지 않고 모달로 띄우는 이유도 같다. 히어로에서 관심이 생긴
|
||||
* 순간에 그 자리에서 받아야 한다. 스크롤로 내려보내면 그 사이에 식는다.
|
||||
*/
|
||||
export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [website, setWebsite] = useState("") // 봇 함정
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle")
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const firstFieldRef = useRef<HTMLInputElement>(null)
|
||||
const restoreFocusTo = useRef<HTMLElement | null>(null)
|
||||
const reduce = useReducedMotion()
|
||||
const titleId = useId()
|
||||
|
||||
/* 열릴 때마다 초기화. 남겨두면 성공 화면이 다시 뜨거나 이전 에러가 붙어 나온다. */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStatus("idle")
|
||||
restoreFocusTo.current = document.activeElement as HTMLElement | null
|
||||
const t = setTimeout(() => firstFieldRef.current?.focus(), 60)
|
||||
return () => clearTimeout(t)
|
||||
}, [open])
|
||||
|
||||
/* 배경 스크롤 잠금. 모달 뒤에서 페이지가 움직이면 모달이 페이지의 일부처럼 읽힌다. */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
restoreFocusTo.current?.focus?.()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* Esc 로 닫기 + Tab 을 모달 안에 가둔다. 가두지 않으면 뒤 페이지의 링크로 포커스가
|
||||
빠져나가고, 키보드·스크린리더 사용자는 자기가 어디 있는지 알 수 없게 된다. */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Tab") return
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), a[href]',
|
||||
)
|
||||
if (!focusables?.length) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
return () => document.removeEventListener("keydown", onKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
const valid = name.trim().length > 0 && isEmailLike(email)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!valid || status === "submitting") return
|
||||
setStatus("submitting")
|
||||
const result = await submitLead({ source: "demo-request", name, email, website })
|
||||
setStatus(result.ok ? "success" : "error")
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-6">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-stage-deep/80 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="relative w-full max-w-md bg-white rounded-card p-8 sm:p-10 shadow-2xl"
|
||||
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.99 }}
|
||||
transition={{ duration: 0.28, ease: EASE_OUT_EXPO }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="absolute top-4 right-4 p-2 text-ink-muted hover:text-ink transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{status === "success" ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 rounded-full bg-positive-soft text-positive flex items-center justify-center mx-auto mb-5">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
|
||||
데모 신청이 접수되었습니다
|
||||
</Typography>
|
||||
{/* 폼에서 "Demo를 보내드립니다"라고 약속했으므로 성공 화면도 같은 약속을 지킨다.
|
||||
여기서 "일정을 잡아 연락드립니다"로 새면 방문자가 무엇을 기다려야 하는지 모른다. */}
|
||||
<Typography variant="small">
|
||||
입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 제목은 여는 버튼과 같은 말을 쓴다. 버튼이 "받기"인데 여기가 "요청"이면
|
||||
전환 순간에 다른 화면에 온 것처럼 읽힌다. */}
|
||||
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-2">
|
||||
실제 데모 받기
|
||||
</Typography>
|
||||
<Typography variant="small" className="mb-8">
|
||||
성함과 이메일만 남겨주시면, 사용해보실 수 있는 Demo를 보내드립니다!
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-7">
|
||||
{/* 봇 함정. 화면에서 감추되 display:none 은 쓰지 않는다 — 일부 봇은
|
||||
숨겨진 필드를 걸러낸다. 스크린리더에는 aria-hidden + tabIndex 로 숨긴다. */}
|
||||
<div className="absolute w-px h-px -left-[9999px] overflow-hidden" aria-hidden>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lead-name" className="block text-[13px] font-semibold text-ink-soft mb-2">
|
||||
성함
|
||||
</label>
|
||||
<Input
|
||||
ref={firstFieldRef}
|
||||
id="lead-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="예: 홍길동"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lead-email" className="block text-[13px] font-semibold text-ink-soft mb-2">
|
||||
이메일
|
||||
</label>
|
||||
<Input
|
||||
id="lead-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder="예: hong@company.co.kr"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "error" && (
|
||||
<p role="alert" className="text-[13px] text-ink-soft leading-[1.6] break-keep">
|
||||
지금은 접수가 어렵습니다.{" "}
|
||||
<a href="#contact-section" onClick={onClose} className="text-primary font-semibold underline">
|
||||
상담 신청
|
||||
</a>
|
||||
으로 남겨주시면 동일하게 연락드립니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={!valid || status === "submitting"}
|
||||
>
|
||||
{status === "submitting" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>접수 중</span>
|
||||
</>
|
||||
) : (
|
||||
<span>데모 신청</span>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@ -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,33 @@ 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>) {
|
||||
/* ComponentPropsWithRef 로 두면 ref 가 그대로 전달된다 (React 19 는 forwardRef 불필요).
|
||||
데모 요청 모달이 열릴 때 첫 필드로 포커스를 옮기려면 ref 가 필요하다. */
|
||||
function Input({ className, ...props }: React.ComponentPropsWithRef<"input">) {
|
||||
return <input className={cn(fieldClass, className)} {...props} />
|
||||
}
|
||||
|
||||
function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea className={cn(fieldClass, "resize-none", className)} {...props} />
|
||||
function Textarea({ className, ...props }: React.ComponentPropsWithRef<"textarea">) {
|
||||
return <textarea className={cn(fieldClass, "resize-none leading-[1.6]", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Input, Textarea }
|
||||
|
||||
179
landing/app/components/ui/negotiation-replay.tsx
Normal file
179
landing/app/components/ui/negotiation-replay.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { motion, useReducedMotion } from "motion/react"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { DEMO_ITEMS, won } from "@/lib/negotiation-sim"
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion"
|
||||
|
||||
/*
|
||||
* 협상 리플레이 — 자동 재생 결과 화면.
|
||||
*
|
||||
* 예전에는 negotiation_annotated_3d.mp4(677K)를 썼다. 영상이라 두 가지를 못 고쳤다.
|
||||
* - 화자가 "아이마켓 구매 MD" 로 픽셀에 구워져 있었다. POC 고객사 이름이라
|
||||
* 다른 MRO 회사에 영업할 때 쓸 수 없다.
|
||||
* - 색이 브랜드 팔레트 밖이었다. 실측하면 말풍선 #5A68CD, 목표가 라벨 #B96175,
|
||||
* 배지 #DB840B — 셋 다 토큰에 없는 색이고, 정작 브랜드의 핵심 대비 장치인
|
||||
* 민트(#0FFFD6, 상대편 색)는 한 번도 안 쓰였다.
|
||||
*
|
||||
* 컴포넌트로 옮기면 둘 다 뿌리에서 사라진다. 이름은 상수고 색은 토큰이라,
|
||||
* 팔레트를 바꾸면 여기도 같이 바뀐다. 에셋 677K 도 0 이 된다.
|
||||
*
|
||||
* 숫자는 지어내지 않고 DEMO_ITEMS[0] 을 그대로 쓴다 — 위쪽 인터랙티브 데모와 같은
|
||||
* 품목·같은 가격이라, 둘을 다 본 사람에게 앞뒤가 맞는다.
|
||||
*/
|
||||
|
||||
const ITEM = DEMO_ITEMS[0]
|
||||
const FINAL = ITEM.target // 1,080,000 — 데모의 목표가와 동일
|
||||
const SAVED = ITEM.listPrice - FINAL
|
||||
const RATE = (SAVED / ITEM.listPrice) * 100
|
||||
|
||||
type Line = { side: "buyer" | "supplier"; text: string; price: number }
|
||||
|
||||
/* 화자는 "구매 담당자" 로 둔다. 회사명을 넣으면 그 회사 전용 자산이 되고,
|
||||
특정 고객사 이름은 영업 대상이 바뀔 때마다 못 쓰게 된다. */
|
||||
const LINES: Line[] = [
|
||||
{ side: "supplier", text: `원자재가 올라서 이번 분기는 ${won(ITEM.listPrice)}원이 최선입니다.`, price: ITEM.listPrice },
|
||||
{ side: "buyer", text: `같은 사양 시장 최저가가 ${won(ITEM.marketLow)}원입니다. 연간 물량을 보증하면 어느 선까지 가능하신가요?`, price: ITEM.marketLow },
|
||||
{ side: "supplier", text: `공정상 한 번에 내리긴 어렵고, ${won(1_140_000)}원까지는 조정하겠습니다.`, price: 1_140_000 },
|
||||
{ side: "buyer", text: `목표가까지 얼마 남지 않았습니다. ${won(FINAL)}원이면 지금 바로 낙찰 처리하겠습니다.`, price: FINAL },
|
||||
{ side: "supplier", text: `좋습니다. ${won(FINAL)}원으로 맞추겠습니다.`, price: FINAL },
|
||||
]
|
||||
|
||||
const STEP_MS = 1600
|
||||
/** 마지막 줄 뒤 결과 카드가 머무는 시간. 짧으면 결론을 못 읽고 지나간다. */
|
||||
const HOLD_MS = 3200
|
||||
|
||||
export function NegotiationReplay({ className }: { className?: string }) {
|
||||
const reduce = useReducedMotion()
|
||||
const [shown, setShown] = useState(reduce ? LINES.length : 0)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const logRef = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
/* 화면 밖에서는 돌리지 않는다. 페이지 최하단이라 대부분의 시간 동안 보이지 않는데
|
||||
타이머만 계속 도는 건 배터리를 쓰는 일이다. */
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { threshold: 0.25 })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (reduce || !inView) return
|
||||
const done = shown >= LINES.length
|
||||
const id = window.setTimeout(() => setShown((n) => (n >= LINES.length ? 0 : n + 1)), done ? HOLD_MS : STEP_MS)
|
||||
return () => clearTimeout(id)
|
||||
}, [shown, inView, reduce])
|
||||
|
||||
// 새 줄이 붙으면 바닥으로 붙인다.
|
||||
useEffect(() => {
|
||||
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: "smooth" })
|
||||
}, [shown])
|
||||
|
||||
const current = shown > 0 ? LINES[shown - 1].price : ITEM.listPrice
|
||||
const settled = shown >= LINES.length
|
||||
/* 정가에서 목표가까지 얼마나 내려왔는지. 1 을 넘지 않게 자른다. */
|
||||
const progress = Math.min(1, (ITEM.listPrice - current) / (ITEM.listPrice - FINAL))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label={`협상 리플레이. 최초 제시가 ${won(ITEM.listPrice)}원에서 ${won(FINAL)}원으로 낙찰되어 ${won(SAVED)}원 절감된 예시입니다.`}
|
||||
>
|
||||
<div className="rounded-card border border-stage-line bg-stage-raised/70 backdrop-blur-sm p-5 sm:p-6">
|
||||
{/* 품목 + 목표가 — 구매 담당자가 위임한 기준이 무엇인지 먼저 보여준다. */}
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-bold text-on-stage truncate">{ITEM.name}</div>
|
||||
<div className="text-[12px] text-on-stage-muted">{ITEM.spec}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-[11px] font-semibold text-on-stage-muted">목표가</div>
|
||||
<div className="text-[14px] font-bold tabular-nums text-on-stage">{won(FINAL)}원</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 제안가 + 진행 막대. 파랑에서 민트로 차오르며 양쪽이 만나는 지점을 그린다. */}
|
||||
<div className="mb-5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold text-on-stage-muted">현재 제안가</span>
|
||||
<span className="text-[22px] font-extrabold tabular-nums tracking-[-0.02em] text-on-stage">
|
||||
{won(current)}
|
||||
<span className="text-[14px] font-semibold text-on-stage-muted ml-0.5">원</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-linear-to-r from-primary-on-stage to-counter"
|
||||
animate={{ width: `${progress * 100}%` }}
|
||||
transition={{ duration: 0.6, ease: EASE_OUT_EXPO }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대화. 높이를 고정해 줄이 늘어도 레이아웃이 튀지 않는다. */}
|
||||
<div ref={logRef} className="h-[210px] overflow-hidden space-y-3 scrollbar-none">
|
||||
{LINES.slice(0, shown).map((line, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={reduce ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
className={`flex flex-col ${line.side === "buyer" ? "items-start" : "items-end"}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[11px] font-semibold mb-1 ${
|
||||
line.side === "buyer" ? "text-primary-on-stage" : "text-counter"
|
||||
}`}
|
||||
>
|
||||
{line.side === "buyer" ? "구매 담당자" : "협력사"}
|
||||
</span>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-card px-3.5 py-2.5 text-[13px] leading-[1.55] break-keep ${
|
||||
line.side === "buyer"
|
||||
? "bg-primary text-white"
|
||||
: "bg-counter-surface text-counter border border-counter/20"
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 낙찰 카드. 자리를 항상 차지하게 두고 내용만 켜고 끈다 — 나타날 때 아래 요소가
|
||||
밀리면 페이지 전체가 주기적으로 흔들린다. */}
|
||||
<div className="mt-4 h-[74px]">
|
||||
{settled && (
|
||||
<motion.div
|
||||
initial={reduce ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: EASE_OUT_EXPO }}
|
||||
className="h-full rounded-card border border-counter/25 bg-counter-surface px-3.5 py-3 flex items-center gap-2.5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-counter/15 text-counter flex items-center justify-center shrink-0">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
{/* 금액은 줄바꿈을 막는다. 카드 높이가 74px 로 고정이라(등장할 때 아래가 밀리지
|
||||
않게 하려고) 한 줄이라도 접히면 잘린다. 좁으면 접히는 게 아니라 붙어야 한다. */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-counter mb-0.5 whitespace-nowrap">협상 타결 → 낙찰</div>
|
||||
<div className="text-[16px] font-extrabold tabular-nums text-on-stage whitespace-nowrap">{won(FINAL)}원</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-auto">
|
||||
<div className="text-[11px] font-semibold text-on-stage-muted mb-0.5 whitespace-nowrap">절감액</div>
|
||||
<div className="text-[15px] font-extrabold tabular-nums text-counter whitespace-nowrap">
|
||||
{won(SAVED)}원 <span className="text-[12px]">▼{RATE.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
landing/app/components/ui/orbit-ring.tsx
Normal file
166
landing/app/components/ui/orbit-ring.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react"
|
||||
|
||||
/* 파이프라인 6단계. 원래 히어로 캔버스가 소유하던 타입인데, 히어로가 영상으로 바뀌면서
|
||||
이리로 옮겼다 — 단계 이름을 실제로 쓰는 건 이제 이 링뿐이다. */
|
||||
export type DataFlowPhase = "scatter" | "cluster" | "stream" | "card" | "negotiate" | "learn"
|
||||
|
||||
/**
|
||||
* 파이프라인 순환 링 — 정원.
|
||||
*
|
||||
* 앞서 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,
|
||||
})
|
||||
|
||||
const LOOP_MS = 10_500 // 히어로 캔버스와 같은 주기
|
||||
|
||||
export function OrbitRing({
|
||||
phase,
|
||||
progressRef,
|
||||
className = "",
|
||||
}: {
|
||||
/** 밖에서 단계를 주면 그걸 따르고, 없으면 자체 시계로 돈다. */
|
||||
phase?: DataFlowPhase
|
||||
/** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1. 없으면 스스로 센다. */
|
||||
progressRef?: RefObject<number>
|
||||
className?: string
|
||||
}) {
|
||||
const headRef = useRef<SVGCircleElement>(null)
|
||||
const [ownIndex, setOwnIndex] = useState(0)
|
||||
|
||||
const activeIndex = phase ? Math.max(0, NODES.findIndex((n) => n.key === phase)) : ownIndex
|
||||
|
||||
/* 궤도를 도는 머리는 rAF 로 직접 갱신한다 — 60fps 로 setState 를 부르면 페이지가 매 프레임 리렌더된다.
|
||||
단계 라벨만 바뀔 때(초당 0.5회) 리렌더한다. */
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let last = -1
|
||||
const startedAt = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const p = progressRef ? (progressRef.current ?? 0) : ((now - startedAt) % LOOP_MS) / LOOP_MS
|
||||
const { x, y } = at(p, R)
|
||||
if (headRef.current) {
|
||||
headRef.current.setAttribute("cx", String(x))
|
||||
headRef.current.setAttribute("cy", String(y))
|
||||
}
|
||||
if (!phase) {
|
||||
const i = Math.min(NODES.length - 1, Math.floor(p * NODES.length))
|
||||
if (i !== last) {
|
||||
last = i
|
||||
setOwnIndex(i)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [progressRef, phase])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
type RangeSliderProps = {
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
/** 채워진 트랙이 값을 따라가는 파란 슬라이더 (ROI 시뮬레이터·히어로 콘솔 공용). */
|
||||
function RangeSlider({ min, max, step, value, onChange }: RangeSliderProps) {
|
||||
const filled = ((value - min) / (max - min)) * 100
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
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"
|
||||
style={{
|
||||
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${filled}%, var(--color-line-strong) ${filled}%, var(--color-line-strong) 100%)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { RangeSlider }
|
||||
@ -3,25 +3,102 @@ import * as React from "react"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/*
|
||||
* 섹션 머리.
|
||||
*
|
||||
* eyebrow 는 선택이고, 기본적으로 쓰지 않는다. 작은 색 라벨을 섹션마다 반복하면
|
||||
* 레퍼런스에서는 영문 대문자라 조판 요소로 기능하지만, 한글은 대문자가 없어서
|
||||
* 그냥 "작은 글씨"가 되고 일곱 번 반복되면 벽지가 된다.
|
||||
* 위계는 라벨이 아니라 헤드라인 크기(본문의 3.5~4배)와 2톤 대비로 만든다.
|
||||
*
|
||||
* ── 세로 리듬 ────────────────────────────────────────────────────────
|
||||
* 머리 안팎의 간격은 전부 이 파일이 소유한다. 섹션 파일에서 각자 mb-12 / mb-16 /
|
||||
* mb-20 / mb-24 를 붙이던 걸 걷어낸 결과다. 같은 관계(머리 → 본문)에 값이 다섯 개
|
||||
* 있으면 스크롤할 때 섹션마다 호흡이 달라지고, 그게 "정돈이 안 됐다"는 인상의
|
||||
* 실제 원인이었다.
|
||||
*
|
||||
* 규칙은 하나다 — 간격이 위계를 만든다. 가까울수록 한 덩어리로 읽힌다.
|
||||
* 값이 흔들리면 "어디까지가 한 덩어리인가"가 섹션마다 달라져 보인다.
|
||||
*
|
||||
* eyebrow → title 16px 라벨은 제목에 붙는다
|
||||
* title → description 20 / 32px 큰 활자는 더 벌린다. 64px 제목 아래 20px 은 붙어 보인다
|
||||
* 머리 → 본문 56 / 80px 섹션 안에서 가장 큰 끊김
|
||||
*
|
||||
* 섹션 파일에서 이 관계들에 mb-· mt- 유틸리티를 직접 쓰지 않는다. 필요하면 아래 상수를
|
||||
* import 해서 쓰고, 새 값이 필요하다고 느껴지면 여기에 이름을 붙여서 추가한다.
|
||||
* 눈대중으로 한 칸씩 조정하기 시작하면 다시 지금 상태로 돌아온다.
|
||||
*/
|
||||
|
||||
/** 머리 → 본문. 머리를 직접 조판하는 섹션(무대 데모)도 이 상수를 쓴다. */
|
||||
export const HEADING_GAP = "mb-14 md:mb-20"
|
||||
/** 탭·토글 행 → 본문. 머리 간격보다 좁아서 컨트롤이 위가 아니라 본문 쪽에 묶인다. */
|
||||
export const CONTROL_GAP = "mb-12"
|
||||
/** 리드 문단 → 버튼 행 (히어로·최종 CTA). */
|
||||
export const CTA_GAP = "mt-12"
|
||||
/** display 티어 제목 → 리드 문단. 히어로는 h1 이라 SectionHeading 을 못 쓰고 이 상수를 직접 쓴다. */
|
||||
export const DISPLAY_LEAD_GAP = "mt-8"
|
||||
|
||||
/*
|
||||
* 제목 조판 티어. 티어가 바뀌면 리드 문단까지의 간격도 같이 움직인다 —
|
||||
* Typography 주석의 "크기·굵기·조임은 같이 움직인다"와 같은 이유로, 간격도 딸려간다.
|
||||
*/
|
||||
const SIZES = {
|
||||
/** 섹션 표준 h2 (34 / 64px) */
|
||||
title: { variant: "title", descriptionGap: "mt-5" },
|
||||
/** 히어로·최종 CTA 급 디스플레이 (38~74px) */
|
||||
display: { variant: "stageDisplay", descriptionGap: DISPLAY_LEAD_GAP },
|
||||
} as const
|
||||
|
||||
type SectionHeadingProps = {
|
||||
eyebrow: string
|
||||
eyebrow?: string
|
||||
title: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
align?: "left" | "center"
|
||||
/** 다크 무대 섹션(bg="stage")에서는 "stage" 를 준다. Typography 변형이 먹색을 물고 있어서 필요하다. */
|
||||
tone?: "light" | "stage"
|
||||
/** 제목 조판 티어. display 는 히어로·최종 CTA 전용. */
|
||||
size?: keyof typeof SIZES
|
||||
/** 머리 아래 표준 간격을 끈다. 뒤에 컨트롤 행이 붙는 섹션에서만 쓴다. */
|
||||
gap?: "heading" | "none"
|
||||
className?: string
|
||||
/** 리드 문단 폭 제한 등 (예: "max-w-2xl") */
|
||||
/** 제목 크기 미세조정 (예: 최종 CTA 의 축소 디스플레이) */
|
||||
titleClassName?: 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",
|
||||
size = "title",
|
||||
gap = "heading",
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
}: SectionHeadingProps) {
|
||||
const onStage = tone === "stage"
|
||||
const { variant, descriptionGap } = SIZES[size]
|
||||
|
||||
return (
|
||||
<div className={cn(align === "center" && "text-center", className)}>
|
||||
<Typography variant="eyebrow" className="mb-3 block">
|
||||
{eyebrow}
|
||||
<div className={cn(align === "center" && "text-center", gap === "heading" && HEADING_GAP, className)}>
|
||||
{/* eyebrow 가 없을 때 빈 span 을 렌더하면 mb + 30px 행간만큼 유령 여백이 생긴다.
|
||||
지금은 모든 섹션이 eyebrow 를 넘기지만, 하나만 빼도 그 섹션만 제목이 내려앉는다. */}
|
||||
{eyebrow && (
|
||||
<Typography variant="eyebrow" className={cn("mb-4 block", onStage && "text-primary-on-stage")}>
|
||||
{eyebrow}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant={variant} as="h2" className={cn(onStage && "text-on-stage", titleClassName)}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="title">{title}</Typography>
|
||||
{description && (
|
||||
<Typography variant="lead" className={cn("mt-5", align === "center" && "mx-auto", descriptionClassName)}>
|
||||
<Typography
|
||||
variant="lead"
|
||||
className={cn(descriptionGap, 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,60 @@ 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).
|
||||
* 굵기만 올리면 둔해지고, 크기만 올리면 헐거워진다.
|
||||
* 1-1) 크기 판단은 "글자가 화면 폭의 몇 %를 쓰는가"로 한다(레퍼런스 statworx 는 48%).
|
||||
* 주의: h1 은 블록이라 getBoundingClientRect 로 재면 컨테이너 폭이 나온다.
|
||||
* Range 로 텍스트 노드를 재야 실제 글자 폭이다 — 이걸 헷갈리면 엉뚱한 값으로 조정하게 된다.
|
||||
* 지금 74px 에서 약 40%. 답답함의 원인은 가로 비율이 아니라 세로 간격이었다.
|
||||
* 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-[38px] sm:text-[52px] md:text-[64px] lg:text-[74px] font-extrabold tracking-[-0.042em] leading-[1.1] 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 +64,7 @@ const typographyVariants = cva("", {
|
||||
|
||||
const defaultTag: Record<NonNullable<VariantProps<typeof typographyVariants>["variant"]>, React.ElementType> = {
|
||||
display: "h1",
|
||||
stageDisplay: "h1",
|
||||
title: "h2",
|
||||
heading: "h3",
|
||||
cardTitle: "h3",
|
||||
|
||||
50
landing/app/lib/lead.ts
Normal file
50
landing/app/lib/lead.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
|
||||
*
|
||||
* 목적지는 같은 오리진의 서버리스 함수(api/lead.ts)다. 랜딩은 ssr:false 정적 빌드지만
|
||||
* Vercel 의 /api 디렉터리 함수는 별개로 배포되므로 서버 없이도 POST 를 받을 수 있다.
|
||||
* 같은 오리진이라 엔드포인트 환경변수도, CORS 도 필요 없다.
|
||||
*
|
||||
* 성공을 위조하지 않는다. 예전 상담 신청 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도
|
||||
* 보내지 않았다 — 화면은 "접수되었습니다"인데 영업이 받을 리드는 없었다.
|
||||
* 여기서는 서버가 리드를 확보했을 때만 ok 를 돌려준다.
|
||||
*/
|
||||
|
||||
const ENDPOINT = "/api/lead"
|
||||
|
||||
export type LeadSource = "demo-request" | "contact"
|
||||
|
||||
export type Lead = {
|
||||
source: LeadSource
|
||||
name: string
|
||||
email: string
|
||||
company?: string
|
||||
phone?: string
|
||||
message?: string
|
||||
/** 봇 함정. 화면에서 감춘 필드라 값이 차 있으면 자동 제출이다. 서버가 조용히 버린다. */
|
||||
website?: string
|
||||
}
|
||||
|
||||
export type LeadResult = { ok: true } | { ok: false; reason: "invalid" | "network" | "rejected" }
|
||||
|
||||
/** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
|
||||
export function isEmailLike(value: string) {
|
||||
const v = value.trim()
|
||||
return v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v)
|
||||
}
|
||||
|
||||
export async function submitLead(lead: Lead): Promise<LeadResult> {
|
||||
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
|
||||
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(lead),
|
||||
})
|
||||
return res.ok ? { ok: true } : { ok: false, reason: "rejected" }
|
||||
} catch {
|
||||
// 로컬 dev(react-router dev)에는 /api 가 없어서 여기로 온다. 확인은 `vercel dev` 로.
|
||||
return { ok: false, reason: "network" }
|
||||
}
|
||||
}
|
||||
198
landing/app/lib/negotiation-sim.ts
Normal file
198
landing/app/lib/negotiation-sim.ts
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 랜딩 협상 데모의 규칙 엔진 (클라이언트 전용).
|
||||
*
|
||||
* 실제 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]
|
||||
|
||||
/* 근거는 반드시 상대가 제시한 값을 물고 있어야 한다.
|
||||
예전엔 첫 카드에서 marketLow 만 읊어서, 얼마를 제시하든 같은 문장이 나왔다.
|
||||
CONCESSION[0] = 0 이라 부르는 값(ask)까지 앵커로 고정이라, 슬라이더를 움직여도
|
||||
화면이 전혀 반응하지 않는 것처럼 보였다. 값은 고수하되(그게 이 에이전트의 성격이다)
|
||||
무엇에 대한 응수인지는 매번 달라져야 한다. */
|
||||
const gap = offer - ask
|
||||
const reason =
|
||||
cardsUsed > 0
|
||||
? `${won(offer)}원과 저희 기준 사이가 아직 ${won(gap)}원 남았습니다.`
|
||||
: offer >= item.listPrice
|
||||
? `정가 그대로는 검토가 어렵습니다. 같은 사양 인터넷 최저가가 ${won(item.marketLow)}원입니다.`
|
||||
: offer > item.marketLow
|
||||
? `${won(offer)}원은 같은 사양 인터넷 최저가 ${won(item.marketLow)}원보다 ${won(
|
||||
offer - item.marketLow,
|
||||
)}원 높습니다.`
|
||||
: `${won(offer)}원이면 시장 최저가 선까지 내려오셨습니다. 다만 이번 견적 기준까지 ${won(gap)}원 남았습니다.`
|
||||
|
||||
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
|
||||
|
||||
/* 에이전트의 첫 수는 사용자가 준 목표가를 물고 있어야 한다. 예전엔 marketLow 만 읊어서
|
||||
목표가를 얼마로 잡든 같은 문장이 나왔다 — 위임한 기준이 반영되는지 확인할 수가 없었다.
|
||||
여는 말(협력사 정가 제시)은 아직 목표가를 모르는 시점이라 상수로 둔다. */
|
||||
const stance =
|
||||
target < item.marketLow
|
||||
? `시장 최저가보다 낮은 목표라 근거부터 깔겠습니다.`
|
||||
: `시장 최저가 선이라 무리 없이 접근하겠습니다.`
|
||||
push(
|
||||
"agent",
|
||||
`목표가 ${won(target)}원 받았습니다. ${stance} 같은 사양 인터넷 최저가가 ${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,16 @@ 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 구매 협상 자동화";
|
||||
/* 한글·영문을 둘 다 담는다. 본문은 "네고시움"으로 갔지만 타이틀에서 영문을 빼면
|
||||
"negotium" 으로 검색해 들어오던 유입이 끊긴다 — 검색 질의는 사용자가 정하지 우리가 못 정한다. */
|
||||
const TITLE = "네고시움(negotium) — AI 구매 협상 자동화";
|
||||
const DESCRIPTION =
|
||||
"가이드라인만 정하면 AI 흥정 봇이 여러 협력사와 단가를 대신 조율합니다. 흥정부터 낙찰까지 자동으로, 협상할수록 강화학습으로 더 좋은 조건을 만드는 B2B 구매 협상 자동화 솔루션.";
|
||||
"품목·목표가·마감일만 정하면 협상 에이전트가 협력사마다 1:1로 단가를 조율하고 낙찰까지 판정합니다. 협상 기록이 쌓일수록 조건이 좋아지는 B2B 구매 협상 자동화 솔루션.";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
@ -30,8 +32,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 />
|
||||
|
||||
69
landing/docs/2026-08-07-hero-cta-copy-decision.md
Normal file
69
landing/docs/2026-08-07-hero-cta-copy-decision.md
Normal file
@ -0,0 +1,69 @@
|
||||
# 히어로 CTA 문구 변경 결정 기록
|
||||
|
||||
- **일자**: 2026-08-07
|
||||
- **상태**: 적용·배포 완료
|
||||
- **범위**: 히어로 CTA 2종, 데모 요청 모달 제목
|
||||
- **관련 커밋**: `77d96cd`
|
||||
|
||||
---
|
||||
|
||||
## 요약
|
||||
|
||||
히어로 상단 CTA 2종의 문구를 변경했다. 최초 지시안을 검토 과정에서 일부 수정했으며,
|
||||
수정안으로 승인·적용·배포를 완료했다.
|
||||
|
||||
## 1. 변경 내역
|
||||
|
||||
| 위치 | 기존 | 최초 지시안 | **최종 적용** |
|
||||
|---|---|---|---|
|
||||
| 1차 CTA | 여기서 직접 체험 | 협상 예시 보기 | **협상 예시 체험** |
|
||||
| 2차 CTA | 데모 요청 | 직접 체험 | **실제 데모 받기** |
|
||||
| 모달 제목 | 데모 요청 | (미지정) | **실제 데모 받기** |
|
||||
|
||||
## 2. 최초 지시안을 수정한 사유
|
||||
|
||||
두 버튼은 클릭 후 경험이 전혀 다르다. 1차는 페이지 내에서 즉시 조작하는 인터랙티브
|
||||
데모(`#how-it-works`)이고, 2차는 이름·이메일을 받는 리드 수집 폼(모달)이다.
|
||||
|
||||
| 지시안 | 확인된 문제 | 사업적 영향 |
|
||||
|---|---|---|
|
||||
| 협상 예시 **보기** | 도착 섹션이 "직접 경험해 보세요", 역할 카드가 "공급사로 해보기"로 조작을 요구함. 버튼은 관람을 약속하고 목적지는 참여를 요구 | 클릭 전 기대와 실제 요구 불일치 → 데모 진입 단계 이탈 |
|
||||
| **직접 체험** | 클릭 시 실제로는 입력 폼이 노출됨. 체험은 이메일 수신 이후 시작 | 즉시 체험을 기대한 방문자의 신뢰 저하 → 리드 폼 이탈 |
|
||||
|
||||
## 3. 최종안이 지시 의도를 유지하는 방식
|
||||
|
||||
최초 지시의 핵심 의도는 **"페이지 내 데모는 시뮬레이션, 이메일 데모가 실제 제품"**
|
||||
이라는 구분이었으며, 최종안은 이를 그대로 반영했다.
|
||||
|
||||
| 요소 | 반영 방식 |
|
||||
|---|---|
|
||||
| 시뮬레이션임을 명시 | "**예시**" 유지 — 고정 데이터 기반임을 표기 |
|
||||
| 목적지와의 정합성 | "**체험**" 유지 — 섹션 헤드라인·역할 카드와 충돌 없음 |
|
||||
| 실제 제품 구분 | "**실제** 데모" — 온페이지 예시와 명확히 분리 |
|
||||
| 전달 경로 명시 | "**받기**" — 이메일 수신 방식임이 드러나 폼 노출이 자연스러움 |
|
||||
|
||||
## 4. 부수 조치
|
||||
|
||||
| 항목 | 조치 | 사유 |
|
||||
|---|---|---|
|
||||
| 모달 제목 | "데모 요청" → "실제 데모 받기" | 버튼과 창 제목 불일치 시 전환 순간 이탈 방지 |
|
||||
| 모달 제출 버튼 | "데모 신청" **유지** | 해당 클릭의 실제 결과는 신청 접수이며, 데모 수령은 이메일 단계에서 발생 |
|
||||
| 데모 섹션 헤드라인 | **변경 없음** | 최종안이 "체험"을 유지하여 수정 불필요 |
|
||||
|
||||
## 5. 적용 원칙
|
||||
|
||||
> **버튼 문구는 클릭 시 실제로 발생하는 동작을 기술한다.**
|
||||
> 문구와 동작이 어긋나면 전환 손실로 직결된다.
|
||||
|
||||
이 원칙은 이후 CTA 문구를 손볼 때 같은 기준으로 적용한다. 특히 아래 두 경우를 주의한다.
|
||||
|
||||
- **목적지와 동사가 어긋나는 경우** — 버튼이 "보기"인데 도착지가 조작을 요구하면 안 된다.
|
||||
- **폼을 여는 버튼에 결과를 약속하는 경우** — "체험"·"시작" 류는 즉시 그 일이 일어날 때만 쓴다.
|
||||
|
||||
## 6. 참고 — 관련 코드 위치
|
||||
|
||||
| 대상 | 파일 |
|
||||
|---|---|
|
||||
| 히어로 CTA 2종 | `app/components/sections/hero-dataflow.tsx` |
|
||||
| 모달 제목·제출 버튼 | `app/components/ui/demo-request-modal.tsx` |
|
||||
| 도착 섹션 헤드라인·역할 카드 | `app/components/sections/negotiation-demo.tsx` |
|
||||
99
landing/docs/lead-webhook.google-apps-script.js
Normal file
99
landing/docs/lead-webhook.google-apps-script.js
Normal file
@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 리드 수신용 Google Apps Script — 데모 요청·상담 신청을 스프레드시트에 적재한다.
|
||||
*
|
||||
* 이 파일은 랜딩 번들에 포함되지 않는다. Google Apps Script 편집기에 붙여넣을 원본이고,
|
||||
* 여기 두는 이유는 배포 후 이 코드가 어디에도 남지 않으면 나중에 아무도 손댈 수 없기 때문이다.
|
||||
*
|
||||
* ── 설치 (약 3분) ────────────────────────────────────────────────────────────
|
||||
* 1. 새 Google 스프레드시트를 만든다. 시트 이름은 그대로 둬도 된다.
|
||||
* 2. 확장 프로그램 → Apps Script → 기본 코드를 지우고 이 파일 전체를 붙여넣는다.
|
||||
* 3. 배포 → 새 배포 → 유형 "웹 앱"
|
||||
* 실행 계정 : 나
|
||||
* 액세스 권한 : 모든 사용자 ← 이걸 "나"로 두면 랜딩에서 호출이 막힌다
|
||||
* 4. 배포하면 나오는 URL(https://script.google.com/macros/s/…/exec)을 복사한다.
|
||||
* 5. Vercel → negotium-landing → Settings → Environment Variables 에
|
||||
* 이름 LEAD_WEBHOOK_URL / 값 복사한 URL / Production 체크
|
||||
* 6. 다시 배포한다. 환경변수는 배포 시점에 주입되므로 재배포 전에는 적용되지 않는다.
|
||||
*
|
||||
* ── 동작 ────────────────────────────────────────────────────────────────────
|
||||
* api/lead.ts 가 아래 형태로 POST 한다. 필드가 늘어도 헤더를 자동으로 확장하므로
|
||||
* 이 스크립트를 다시 고칠 일은 거의 없다.
|
||||
* { text, source, name, email, company, phone, message, submittedAt, userAgent }
|
||||
*
|
||||
* 주의: 웹 앱은 POST 에 302 를 돌려주고 script.googleusercontent.com 으로 넘긴다.
|
||||
* api/lead.ts 의 fetch 는 리다이렉트를 따라가므로 최종 200 을 받는다 — 정상이다.
|
||||
*/
|
||||
|
||||
/** 리드가 쌓일 시트 이름. 없으면 자동 생성한다. */
|
||||
var SHEET_NAME = 'leads'
|
||||
|
||||
/** 항상 이 순서로 왼쪽부터 채운다. 나머지 필드는 뒤에 자동으로 붙는다. */
|
||||
var PREFERRED = ['submittedAt', 'source', 'name', 'email', 'company', 'phone', 'message', 'userAgent']
|
||||
|
||||
function doPost(e) {
|
||||
try {
|
||||
var payload = JSON.parse((e && e.postData && e.postData.contents) || '{}')
|
||||
|
||||
// text 는 Slack 전용 요약이라 시트에는 넣지 않는다. 같은 내용이 개별 필드에 이미 있다.
|
||||
delete payload.text
|
||||
|
||||
var lock = LockService.getScriptLock()
|
||||
lock.waitLock(20000) // 동시 제출이 같은 행에 겹쳐 쓰는 것을 막는다
|
||||
try {
|
||||
var sheet = getSheet_()
|
||||
var header = ensureHeader_(sheet, payload)
|
||||
var row = header.map(function (key) {
|
||||
return payload[key] === undefined ? '' : payload[key]
|
||||
})
|
||||
sheet.appendRow(row)
|
||||
} finally {
|
||||
lock.releaseLock()
|
||||
}
|
||||
|
||||
return json_({ ok: true })
|
||||
} catch (err) {
|
||||
// 실패해도 랜딩 쪽 api/lead.ts 가 이미 로그를 남겼으므로 리드 자체는 보존된다.
|
||||
return json_({ ok: false, error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
/** 브라우저로 URL 을 열었을 때 살아있는지 확인용. 배포 직후 점검에 쓴다. */
|
||||
function doGet() {
|
||||
return json_({ ok: true, service: 'negotium lead sink' })
|
||||
}
|
||||
|
||||
function getSheet_() {
|
||||
var ss = SpreadsheetApp.getActiveSpreadsheet()
|
||||
return ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME)
|
||||
}
|
||||
|
||||
/** 헤더가 없으면 만들고, 처음 보는 필드가 오면 열을 덧붙인다. */
|
||||
function ensureHeader_(sheet, payload) {
|
||||
var lastCol = sheet.getLastColumn()
|
||||
var header = lastCol ? sheet.getRange(1, 1, 1, lastCol).getValues()[0].filter(String) : []
|
||||
|
||||
if (!header.length) {
|
||||
header = PREFERRED.filter(function (k) {
|
||||
return k in payload
|
||||
})
|
||||
Object.keys(payload).forEach(function (k) {
|
||||
if (header.indexOf(k) === -1) header.push(k)
|
||||
})
|
||||
sheet.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold')
|
||||
sheet.setFrozenRows(1)
|
||||
return header
|
||||
}
|
||||
|
||||
var added = Object.keys(payload).filter(function (k) {
|
||||
return header.indexOf(k) === -1
|
||||
})
|
||||
if (added.length) {
|
||||
sheet.getRange(1, header.length + 1, 1, added.length).setValues([added]).setFontWeight('bold')
|
||||
header = header.concat(added)
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
function json_(obj) {
|
||||
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON)
|
||||
}
|
||||
2422
landing/package-lock.json
generated
2422
landing/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,13 +13,13 @@
|
||||
"@react-router/node": "^7.17.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-router": "^7.17.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"isbot": "^5"
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.17.0",
|
||||
@ -27,6 +27,7 @@
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.1",
|
||||
"@vercel/node": "^5.9.5",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3"
|
||||
|
||||
BIN
landing/public/fonts/PlayfairDisplay-Italic.woff2
Normal file
BIN
landing/public/fonts/PlayfairDisplay-Italic.woff2
Normal file
Binary file not shown.
BIN
landing/public/gifs/hero_loop.jpg
Normal file
BIN
landing/public/gifs/hero_loop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
landing/public/gifs/hero_loop.mp4
Normal file
BIN
landing/public/gifs/hero_loop.mp4
Normal file
Binary file not shown.
7
landing/vercel.json
Normal file
7
landing/vercel.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"framework": null,
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": "build/client",
|
||||
"rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user