[feat] landing: 데모 요청 리드 캡처 + 히어로·데모 카피 교체

카피
- 데모 섹션 헤드라인: "직접 협상해 보세요 / 에이전트는 정해진 기준을 벗어나지 않습니다"
  → "사용할수록 진화하는 에이전틱 협상, / 직접 경험해 보세요!"
  예전 카피는 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 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-08-06 10:40:28 +09:00
parent f214fccc49
commit c53eeefcf6
5 changed files with 288 additions and 8 deletions

View File

@ -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 (
<section
data-stage-hero
@ -77,17 +81,21 @@ export function HeroDataFlow() {
.
</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>
<span> </span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
</Button>
<Button href="#contact-section" variant="stageGhost" size="stageRound">
<Button type="button" variant="stageGhost" size="stageRound" onClick={() => setDemoOpen(true)}>
</Button>
</div>
</div>
</div>
<DemoRequestModal open={demoOpen} onClose={() => setDemoOpen(false)} />
</section>
)
}

View File

@ -129,16 +129,22 @@ export function NegotiationDemo() {
20px( 12px)
. */}
{/* .
"에이전트는 기준 / 밖으로 안 나갑니다" . */}
.
"에이전트는 정해진 기준을 벗어나지 않습니다" . FAQ 2
("에이전트가 우리 기준을 벗어나 합의해 버리면요?")
, .
.
FAQ . */}
<SectionHeading
align="center"
tone="stage"
eyebrow="Try it Yourself"
title={
<>
<span className="text-white"> .</span>
<span className="text-white"> ,</span>
<br />
<span className="text-on-stage-soft/60"> .</span>
<span className="text-on-stage-soft/60"> !</span>
</>
}
/>

View File

@ -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<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({ name, email })
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>
<Typography variant="small">
.
</Typography>
</div>
) : (
<>
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-2">
</Typography>
<Typography variant="small" className="mb-8">
, .
</Typography>
<form onSubmit={handleSubmit} className="space-y-7">
<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>
)
}

View File

@ -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<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>) {
function Textarea({ className, ...props }: React.ComponentPropsWithRef<"textarea">) {
return <textarea className={cn(fieldClass, "resize-none leading-[1.6]", className)} {...props} />
}

52
landing/app/lib/lead.ts Normal file
View File

@ -0,0 +1,52 @@
/*
* .
*
* (contact.tsx) 1.2 .
* "접수되었습니다"
* , .
*
* . ,
* ( ) .
*
* VITE_LEAD_ENDPOINT . ssr:false
* , (Formspree ) Vercel URL .
*/
export type LeadResult = { ok: true } | { ok: false; reason: "not-configured" | "network" | "rejected" }
export type Lead = { name: string; email: string }
/** 아주 느슨한 형식 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */
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> {
const endpoint = import.meta.env.VITE_LEAD_ENDPOINT as string | undefined
if (!endpoint) {
console.warn(
"[lead] VITE_LEAD_ENDPOINT 가 설정되지 않아 데모 요청이 저장되지 않습니다. " +
"폼 백엔드 URL 을 환경변수로 넣어주세요.",
)
return { ok: false, reason: "not-configured" }
}
try {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
name: lead.name.trim(),
email: lead.email.trim(),
// 어느 CTA 에서 들어온 리드인지 남긴다. 유입 경로별 전환율을 나중에 못 재면 개선할 수 없다.
source: "landing:demo-request",
submittedAt: new Date().toISOString(),
}),
})
return res.ok ? { ok: true } : { ok: false, reason: "rejected" }
} catch {
return { ok: false, reason: "network" }
}
}