[feat] landing: 리드 수집 서버리스 함수 + 상담 신청 폼 실전송 연결
랜딩은 ssr:false 정적 빌드라 서버 라우트가 없다. Vercel /api 디렉터리 함수로 받는다.
클라이언트가 같은 오리진(/api/lead)으로 쏘므로 엔드포인트 환경변수도 CORS 도 없다.
api/lead.ts — 리드를 잃지 않는 것이 유일한 책임
1) 먼저 구조화 로그를 남긴다. 웹훅이 죽어도 Vercel 로그에 리드가 남는다.
2) LEAD_WEBHOOK_URL 이 있으면 전달(Slack Incoming Webhook·Zapier·자체 API 모두 POST).
Slack 용 text 와 원본 필드를 함께 실어 수신처를 바꿔도 이 파일을 안 고친다.
3) 웹훅 실패해도 200 — 1) 에서 리드를 확보했으니 방문자에게 재입력을 시킬 이유가 없다.
대신 실패는 로그에 크게 남긴다.
봇 함정(website) 값이 차 있으면 조용히 200. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다.
contact.tsx — 가짜 성공 제거
기존엔 setTimeout 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않았다. 화면은
"접수되었습니다"인데 영업이 받을 리드는 없었다. 이제 같은 함수로 보내고 서버가
리드를 확보했을 때만 성공을 띄운다. 실패 시 role="alert" 로 안내.
vercel.json — SPA 리라이트가 /api 를 삼키지 않도록 제외.
"/(.*)" 그대로 두면 /api/lead 요청이 index.html 로 갔다.
데모 요청 모달 카피
"이름과 이메일만 남겨주시면, 담당자가 일정을 잡아 연락드립니다"
→ "성함과 이메일만 남겨주시면, 사용해보실 수 있는 Demo를 보내드립니다!"
성공 화면도 같은 약속으로 맞췄다. 폼은 데모를 보낸다는데 성공 화면이 일정을
잡는다고 하면 방문자가 무엇을 기다려야 하는지 모른다. 라벨도 성함으로 통일.
⚠️ LEAD_WEBHOOK_URL 미설정 시 리드는 Vercel 로그에만 남는다. 로그는 최후의 그물이지
CRM 이 아니다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c53eeefcf6
commit
2888ff2c28
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")
|
||||
}
|
||||
@ -8,6 +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 { submitLead } from "@/lib/lead"
|
||||
|
||||
const EMPTY_FORM = {
|
||||
companyName: '',
|
||||
@ -17,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>) => {
|
||||
@ -124,6 +139,14 @@ export function Contact() {
|
||||
/>
|
||||
</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>
|
||||
|
||||
@ -21,6 +21,7 @@ import { isEmailLike, submitLead } from "@/lib/lead"
|
||||
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)
|
||||
@ -82,7 +83,7 @@ export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
e.preventDefault()
|
||||
if (!valid || status === "submitting") return
|
||||
setStatus("submitting")
|
||||
const result = await submitLead({ name, email })
|
||||
const result = await submitLead({ source: "demo-request", name, email, website })
|
||||
setStatus(result.ok ? "success" : "error")
|
||||
}
|
||||
|
||||
@ -126,8 +127,10 @@ export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
|
||||
데모 신청이 접수되었습니다
|
||||
</Typography>
|
||||
{/* 폼에서 "Demo를 보내드립니다"라고 약속했으므로 성공 화면도 같은 약속을 지킨다.
|
||||
여기서 "일정을 잡아 연락드립니다"로 새면 방문자가 무엇을 기다려야 하는지 모른다. */}
|
||||
<Typography variant="small">
|
||||
담당자가 이메일로 일정과 준비 사항을 안내해 드립니다.
|
||||
입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
@ -136,13 +139,26 @@ export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
데모 요청
|
||||
</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}
|
||||
|
||||
@ -1,52 +1,50 @@
|
||||
/*
|
||||
* 데모 요청 리드 전송.
|
||||
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
|
||||
*
|
||||
* 기존 문의 폼(contact.tsx)은 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않는다.
|
||||
* 리드 제너레이션에서 그 방식은 최악이다 — 화면은 "접수되었습니다"라고 하는데
|
||||
* 영업이 받을 리드는 존재하지 않고, 아무도 그 사실을 모른다.
|
||||
* 목적지는 같은 오리진의 서버리스 함수(api/lead.ts)다. 랜딩은 ssr:false 정적 빌드지만
|
||||
* Vercel 의 /api 디렉터리 함수는 별개로 배포되므로 서버 없이도 POST 를 받을 수 있다.
|
||||
* 같은 오리진이라 엔드포인트 환경변수도, CORS 도 필요 없다.
|
||||
*
|
||||
* 그래서 여기서는 성공을 위조하지 않는다. 엔드포인트가 없으면 없다고 돌려주고,
|
||||
* 호출부는 그걸 실패로 처리해 방문자에게 다른 경로(상담 신청)를 안내한다.
|
||||
*
|
||||
* 엔드포인트는 VITE_LEAD_ENDPOINT 로 주입한다. 랜딩은 ssr:false 정적 빌드라
|
||||
* 서버 라우트가 없으므로, 폼 백엔드(Formspree 류)나 Vercel 서버리스 함수 URL 을 넣는다.
|
||||
* 성공을 위조하지 않는다. 예전 상담 신청 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도
|
||||
* 보내지 않았다 — 화면은 "접수되었습니다"인데 영업이 받을 리드는 없었다.
|
||||
* 여기서는 서버가 리드를 확보했을 때만 ok 를 돌려준다.
|
||||
*/
|
||||
|
||||
export type LeadResult = { ok: true } | { ok: false; reason: "not-configured" | "network" | "rejected" }
|
||||
const ENDPOINT = "/api/lead"
|
||||
|
||||
export type Lead = { name: string; email: string }
|
||||
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> {
|
||||
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" }
|
||||
}
|
||||
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
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(),
|
||||
}),
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@ -3,5 +3,5 @@
|
||||
"framework": null,
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": "build/client",
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||
"rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user