카피
- 데모 섹션 헤드라인: "직접 협상해 보세요 / 에이전트는 정해진 기준을 벗어나지 않습니다"
→ "사용할수록 진화하는 에이전틱 협상, / 직접 경험해 보세요!"
예전 카피는 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>
213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
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>
|
|
)
|
|
}
|