랜딩은 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>
115 lines
4.6 KiB
TypeScript
115 lines
4.6 KiB
TypeScript
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")
|
|
}
|