o2o-negosium-original/landing/app/components/ui/demo-request-modal.tsx
Haewon Kam 77d96cd4a4 [fix] landing: 히어로 CTA 문구 — 클릭 후 일어나는 일에 맞춘다
여기서 직접 체험  →  협상 예시 체험
  데모 요청        →  실제 데모 받기

두 버튼은 성격이 전혀 다른데 문구가 그걸 구분하지 못했다.
1차는 페이지 안에서 바로 조작하는 인터랙티브 데모고, 2차는 이름·이메일을 받는
리드 폼이다. 클릭 직후 경험이 다르므로 문구도 갈라야 한다.

협상 예시 체험
  고정 데이터 시뮬레이션이라 "예시"를 밝힌다. 다만 "보기"로 낮추지는 않았다 —
  도착 섹션이 "직접 경험해 보세요" 이고 역할 카드가 "공급사로 해보기" 라,
  버튼만 구경하는 말이면 클릭 전 기대와 도착 후 요구가 어긋난다.

실제 데모 받기
  누르면 폼이 뜨고 데모는 이메일로 온다. "직접 체험" 이라 쓰면 눌렀을 때 바로
  만질 줄 알았다가 입력을 요구받아 낚인 느낌이 된다. "받기" 는 이메일로 온다는
  사실이 드러나서 폼이 뜨는 게 자연스럽다.

모달 제목도 "데모 요청" → "실제 데모 받기" 로 맞췄다. 버튼이 "받기" 인데 열린 창이
"요청" 이면 전환 순간에 다른 화면처럼 읽힌다.

제출 버튼은 "데모 신청" 그대로 둔다. 같은 원칙을 적용하면 그 클릭으로 실제 일어나는
일은 신청 접수이지 데모 수령이 아니다(수령은 이메일로 나중에).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:47:07 +09:00

231 lines
9.7 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 [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>
)
}