본문 바로가기

상담문의(리드) 연동

submitInquiry 서버 액션으로 문의 폼을 어드민 CRM에 연결

사이트의 문의 폼 제출을 RootTale로 보내면 어드민 CRM(받은문의) 에서 관리됩니다. 블로그 조회와 같은 API 키 하나로 동작하며, 키가 테넌트를 식별하므로 별도 식별자가 필요 없습니다. 개인정보(이름·연락처 등)⁠는 서버에서 암호화 저장됩니다.

권장 — Next.js Server Action

// lib/actions/submit-contact.ts
"use server";

import { submitInquiry } from "@roottale/cms-client/server";

export interface ContactState {
  status: "idle" | "success" | "error";
  message?: string;
  errors?: Partial<Record<"name" | "phone" | "privacyConsent", string>>;
}

export async function submitContact(
  _prev: ContactState,
  formData: FormData,
): Promise<ContactState> {
  const name = (formData.get("name") as string | null)?.trim() ?? "";
  const phone = (formData.get("phone") as string | null)?.trim() ?? "";
  const message = (formData.get("message") as string | null)?.trim() ?? "";
  const turnstileToken =
    (formData.get("cf-turnstile-response") as string | null)?.trim() ?? "";
  const privacyConsent = formData.get("privacyConsent") !== null;

  const errors: ContactState["errors"] = {};
  if (!name) errors.name = "이름을 입력해주세요.";
  if (!phone || phone.length < 7) errors.phone = "연락처를 입력해주세요.";
  if (!privacyConsent) errors.privacyConsent = "개인정보 수집·이용에 동의해주세요.";
  if (Object.keys(errors).length > 0) {
    return { status: "error", message: "필수 항목을 입력해주세요.", errors };
  }

  const result = await submitInquiry({
    apiKey: process.env.ROOTTALE_API_KEY!,
    baseUrl: process.env.ROOTTALE_API_BASE,
    fields: {
      vertical: "tax", // consulting | medical | tax | legal
      contactName: name,
      businessName: name, // 사업체명 미수집 폼이면 이름으로 대체
      email: `noemail-${name}@example.invalid`, // 이메일 미수집 폼이면 placeholder
      phone, // 자동으로 010-1234-5678 형태 포맷됨
      message: message || undefined,
      turnstileToken: turnstileToken || undefined,
      privacyConsent: true, // 사용자가 명시 동의한 경우에만 true
    },
  });

  if (result.ok) {
    return { status: "success", message: "상담 문의가 접수되었습니다." };
  }
  return { status: "error", message: result.message };
}

클라이언트 폼에서는 useActionState(submitContact, { status: "idle" })로 연결합니다.

필드 레퍼런스 (SubmitInquiryFields)

필드 필수 설명
vertical consulting | medical | tax | legal
contactName 이름
businessName 사업체명 (미수집 시 이름으로 대체)
email 이메일 (.+@.+\..+)
phone 전화번호 (자동 한국식 포맷)
privacyConsent 개인정보 수집·이용 동의 — 반드시 사용자 명시 동의
message 문의 내용
consultationField 상담 분야 라벨
currentSiteUrl 현재 사이트 URL
overseasTransferConsent medical 시 ✅ 국외이전 동의
leadKind patient(기본) | sales
turnstileToken Cloudflare Turnstile 토큰. tenant-api secret 검증을 켠 경우 전달
extras 임의 추가 항목 (최대 50개, 암호화 보관, CRM 상세에 노출)
attribution 유입 first-touch/last-touch — 아래 유입 어트리뷰션 참고
journey 방문 여정 JSON 원문 — 아래 방문 여정 참고

extras 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영하세요. journey 함께 보낼 때는 개인정보 수집·이용 동의 문구에 "사이트 이용 기록 (방문 페이지·상호작용)"을 추가하세요.

유입 어트리뷰션 (attribution)

문의가 어느 글·검색·단축링크/QR에서 왔는지를 CRM에 표시하려면 두 줄만 추가하면 됩니다. RootTale 비콘이 방문자의 first-touch(처음 도착한 경로·rt_src 토큰·utm·외부 referrer 호스트명)⁠를 30일간 기억하며, readAttribution()(브라우저 전용, @roottale/cms-client/attribution)⁠으로 읽습니다. 식별자가 아니므로 개인정보가 아닙니다.

  1. 폼 안에 hidden input 추가 (클라이언트 컴포넌트):
"use client";
import { useEffect, useState } from "react";
import { readAttribution } from "@roottale/cms-client/attribution";

export function AttributionField() {
  const [value, setValue] = useState("");
  useEffect(() => {
    const attribution = readAttribution();
    if (attribution) setValue(JSON.stringify(attribution));
  }, []);
  return <input type="hidden" name="attribution" value={value} />;
}
// 사용: <form action={...}> ... <AttributionField /> ... </form>
  1. Server Action에서 파싱해 전달:
import { parseAttributionJson, submitInquiry } from "@roottale/cms-client/server";

const attribution = parseAttributionJson(formData.get("attribution")); // 깨진 값은 null
const result = await submitInquiry({
  apiKey: process.env.ROOTTALE_API_KEY!,
  fields: { /* ...표준 필드 */ attribution },
});

InquiryAttribution 형태 (모든 필드 선택):

필드 설명
landing_path 첫 방문 landing pathname
rt_src 단축링크/QR 토큰 (roottale.link 경유 시)
utm_source / utm_medium / utm_campaign UTM 파라미터
utm_term / utm_content UTM 파라미터(세부)
ad_click 광고 클릭 유입 존재 플래그(boolean, true일 때만 존재) — gclid/fbclid 원값은 저장하지 않습니다
referrer 외부 referrer 호스트명 (raw URL 아님)
first_touch_at first-touch 시각 (ISO 8601)
last_touch_at 가장 최근 non-direct 방문 시각(ISO 8601) — first-touch와 별개로 갱신
last_touch_channel last-touch를 유발한 채널 — rs(단축링크/QR) | utm | gcl | rf
last_touch_source / last_touch_medium / last_touch_campaign / last_touch_referrer last-touch 시점의 UTM/referrer

직접 HTTP 연동 시에는 같은 값을 attr_landing_path, attr_rt_src, attr_utm_source, attr_utm_medium, attr_utm_campaign, attr_utm_term, attr_utm_content, attr_ad_click, attr_referrer, attr_first_touch_at, attr_last_touch_at, attr_last_touch_channel, attr_last_touch_source, attr_last_touch_medium, attr_last_touch_campaign, attr_last_touch_referrer 폼 필드로 보내면 됩니다.

방문 여정 (journey)

문의 제출 직전까지의 방문 경로(페이지 이동·주요 전환 이벤트)⁠를 CRM 상세의 "방문 여정" 패널에서 시간순으로 확인할 수 있습니다. @roottale/analytics-runtime readJourney() 읽은 sessionStorage 링버퍼를 JSON.stringify한 문자열을 그대로 journey 필드(직접 HTTP는 attr_journey 폼 필드)⁠로 보내면 됩니다 — 파싱·검증은 서버가 담당합니다.

의료(medical)·법률(legal) 업종 문의는 서버가 자동으로 "마일스톤 모드"(이벤트 유형·시각만, 방문 페이지 정보 제거)⁠로 강제 전환합니다 — 증상·질환 관심사가 노출되지 않도록 하는 안전장치이므로 별도 설정이 필요 없습니다.

에러 처리

submitInquiry throw 하지 않고 구조화된 결과를 반환합니다:

type SubmitInquiryResult =
  | { ok: true }
  | { ok: false; code: string | null; message: string }; // message = 한국어 사용자 메시지
code 의미
consent_privacy 개인정보 동의 누락
consent_overseas medical인데 국외이전 동의 누락
missing_fields 필수 필드 누락
invalid_email 이메일 형식 오류
invalid_vertical 허용되지 않는 vertical
turnstile_failed Turnstile 검증 실패
rate_limited 같은 tenant/IP에서 요청 과다
invalid_api_key 키 인증 실패
internal 서버/네트워크 오류

대안 — RootTaleLeadForm 컴포넌트

자체 폼 없이 빠르게 붙일 때는 @roottale/cms-renderer-next RootTaleLeadForm(RSC, HTML form)⁠을 사용할 수 있습니다. 디자인·검증을 통제하려면 위의 Server Action 방식을 권장합니다.

RootTaleLeadForm turnstileSiteKey 넘기면 Cloudflare Turnstile 위젯과 cf-turnstile-response 필드가 함께 렌더링됩니다.

raw HTTP로 직접 연동(비 JS 스택)하려면 api-reference.md POST /v1/public/inquiries 참고하세요.