From c53eeefcf6bfc1cb8ad4de0de57d0691186bec68 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 6 Aug 2026 10:40:28 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20landing:=20=EB=8D=B0=EB=AA=A8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=A6=AC=EB=93=9C=20=EC=BA=A1=EC=B2=98=20?= =?UTF-8?q?+=20=ED=9E=88=EC=96=B4=EB=A1=9C=C2=B7=EB=8D=B0=EB=AA=A8=20?= =?UTF-8?q?=EC=B9=B4=ED=94=BC=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카피 - 데모 섹션 헤드라인: "직접 협상해 보세요 / 에이전트는 정해진 기준을 벗어나지 않습니다" → "사용할수록 진화하는 에이전틱 협상, / 직접 경험해 보세요!" 예전 카피는 FAQ 2번("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로 끌어올린 것이었다. 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었고, 부정문이라 "안 하는 일"을 약속했다 — 체험을 권하는 자리에 맞지 않는다. 방어는 FAQ 에 그대로 남아 있다. - 히어로 1차 CTA: "직접 협상해 보기" → "여기서 직접 체험" - 히어로 2차 CTA: "도입 문의" → "데모 요청". 기존엔 #contact-section 으로 가서 GNB "상담 신청" 과 목적지가 같았다 — 버튼만 둘이고 경로는 하나였다. 리드 캡처 - DemoRequestModal 신규. 이름·이메일 두 칸만 받는다. 필드가 늘수록 이탈이 늘고, 영업 첫 연락에 필요한 최소가 그 둘이다. 나머지는 통화에서 채운다. - 모달로 띄우는 이유: 히어로에서 관심이 생긴 자리에서 받아야 한다. 스크롤로 내려보내면 그 사이에 식는다. 고관여 폼(상담 신청)은 그대로 두고 그 앞단을 만든 것. - 접근성: Esc 닫기, Tab 을 모달 안에 가둠, 열릴 때 첫 필드 포커스·닫힐 때 복원, aria-modal/labelledby, 배경 스크롤 잠금, prefers-reduced-motion 대응. 전송 경로 — 성공을 위조하지 않는다 contact.tsx 는 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않는다("백엔드 미연결"). 리드 제너레이션에서 그 방식은 최악이다. 화면은 접수됐다고 하는데 영업이 받을 리드는 없고, 아무도 그 사실을 모른다. 그래서 lib/lead.ts 는 VITE_LEAD_ENDPOINT 가 없으면 not-configured 를 돌려주고, 모달은 그걸 실패로 처리해 상담 신청 경로를 안내한다. ⚠️ VITE_LEAD_ENDPOINT 를 설정하기 전까지 데모 요청은 저장되지 않는다. Co-Authored-By: Claude Opus 5 --- .../app/components/sections/hero-dataflow.tsx | 14 +- .../components/sections/negotiation-demo.tsx | 12 +- .../app/components/ui/demo-request-modal.tsx | 212 ++++++++++++++++++ landing/app/components/ui/input.tsx | 6 +- landing/app/lib/lead.ts | 52 +++++ 5 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 landing/app/components/ui/demo-request-modal.tsx create mode 100644 landing/app/lib/lead.ts diff --git a/landing/app/components/sections/hero-dataflow.tsx b/landing/app/components/sections/hero-dataflow.tsx index 9d24cf2..eee2c8d 100644 --- a/landing/app/components/sections/hero-dataflow.tsx +++ b/landing/app/components/sections/hero-dataflow.tsx @@ -1,6 +1,8 @@ +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" @@ -18,6 +20,8 @@ import { Typography } from "@/components/ui/typography" * - 카피는 화면 중앙 정렬. 밴드 개념이 없으니 상하로 나눌 이유도 없다. */ export function HeroDataFlow() { + const [demoOpen, setDemoOpen] = useState(false) + return (
+ {/* 2차 CTA 는 상담 신청(#contact-section)과 목적지가 겹쳐 사실상 같은 버튼이었다. + 데모 요청으로 갈라서, 고관여 폼(상담 신청) 앞단에 저관여 리드 캡처를 둔다. */}
-
+ + setDemoOpen(false)} />
) } diff --git a/landing/app/components/sections/negotiation-demo.tsx b/landing/app/components/sections/negotiation-demo.tsx index 651fd3e..a68416d 100644 --- a/landing/app/components/sections/negotiation-demo.tsx +++ b/landing/app/components/sections/negotiation-demo.tsx @@ -129,16 +129,22 @@ export function NegotiationDemo() { 아이브로우 간격만 20px(다른 섹션은 12px)이었다 — 프리미티브를 우회하면 같은 관계에 두 번째 값이 생긴다. */} {/* 두 문장은 의미 단위가 다르므로 줄을 명시적으로 나눈다. - 자동 줄바꿈에 맡기면 "에이전트는 기준 / 밖으로 안 나갑니다" 처럼 끊긴다. */} + 자동 줄바꿈에 맡기면 어절이 어색하게 끊긴다. + + 예전 카피는 "에이전트는 정해진 기준을 벗어나지 않습니다" 였다. FAQ 2번 + ("에이전트가 우리 기준을 벗어나 합의해 버리면요?")을 헤드라인으로 끌어올린 + 것이었는데, 아직 생기지도 않은 반론에 가장 비싼 한 줄을 쓰고 있었다. + 게다가 부정문이라 안 하는 일을 약속했다 — 체험을 권하는 자리에는 맞지 않는다. + 방어는 FAQ 에 그대로 남아 있으니 여기서는 능력을 말한다. */} - 직접 협상해 보세요. + 사용할수록 진화하는 에이전틱 협상,
- 에이전트는 정해진 기준을 벗어나지 않습니다. + 직접 경험해 보세요! } /> diff --git a/landing/app/components/ui/demo-request-modal.tsx b/landing/app/components/ui/demo-request-modal.tsx new file mode 100644 index 0000000..87e90dc --- /dev/null +++ b/landing/app/components/ui/demo-request-modal.tsx @@ -0,0 +1,212 @@ +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 [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle") + const dialogRef = useRef(null) + const firstFieldRef = useRef(null) + const restoreFocusTo = useRef(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( + '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({ name, email }) + setStatus(result.ok ? "success" : "error") + } + + return ( + + {open && ( +
+ + + + + {status === "success" ? ( +
+
+ +
+ + 데모 신청이 접수되었습니다 + + + 담당자가 이메일로 일정과 준비 사항을 안내해 드립니다. + +
+ ) : ( + <> + + 데모 요청 + + + 이름과 이메일만 남겨주시면, 담당자가 일정을 잡아 연락드립니다. + + +
+
+ + setName(e.target.value)} + disabled={status === "submitting"} + /> +
+ +
+ + setEmail(e.target.value)} + disabled={status === "submitting"} + /> +
+ + {status === "error" && ( +

+ 지금은 접수가 어렵습니다.{" "} + + 상담 신청 + + 으로 남겨주시면 동일하게 연락드립니다. +

+ )} + + +
+ + )} +
+
+ )} +
+ ) +} diff --git a/landing/app/components/ui/input.tsx b/landing/app/components/ui/input.tsx index 380665d..20b342f 100644 --- a/landing/app/components/ui/input.tsx +++ b/landing/app/components/ui/input.tsx @@ -21,11 +21,13 @@ const fieldClass = "outline-none transition-colors hover:border-ink-muted focus:border-primary " + "disabled:opacity-50 disabled:cursor-not-allowed" -function Input({ className, ...props }: React.InputHTMLAttributes) { +/* ComponentPropsWithRef 로 두면 ref 가 그대로 전달된다 (React 19 는 forwardRef 불필요). + 데모 요청 모달이 열릴 때 첫 필드로 포커스를 옮기려면 ref 가 필요하다. */ +function Input({ className, ...props }: React.ComponentPropsWithRef<"input">) { return } -function Textarea({ className, ...props }: React.TextareaHTMLAttributes) { +function Textarea({ className, ...props }: React.ComponentPropsWithRef<"textarea">) { return