폼 전송 TDD추가 및 암호화 적용 중
This commit is contained in:
@@ -43,6 +43,7 @@ export async function POST(request: Request) {
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
|
||||
@@ -58,6 +58,7 @@ export async function POST(request: Request) {
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
|
||||
@@ -365,11 +365,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const configJson = JSON.stringify(props ?? {});
|
||||
const escapedConfig = escapeAttr(configJson);
|
||||
|
||||
const projectSlugRaw = (projectConfig?.slug ?? "").trim();
|
||||
const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
||||
);
|
||||
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
||||
if (projectSlugValue) {
|
||||
parts.push(
|
||||
`<input type="hidden" name="__projectSlug" value="${escapeAttr(projectSlugValue)}" />`,
|
||||
);
|
||||
}
|
||||
parts.push("</form>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,158 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { FormBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { FormBlockProps, FormFieldConfig } from "@/features/editor/state/editorStore";
|
||||
import { encryptJson } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient() as any;
|
||||
|
||||
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
|
||||
const result = new Set<string>();
|
||||
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
|
||||
|
||||
for (const field of fields) {
|
||||
const name = (field.name ?? "").toLowerCase();
|
||||
const label = (field.label ?? "").toLowerCase();
|
||||
if (!name) continue;
|
||||
|
||||
const isEmail =
|
||||
field.type === "email" ||
|
||||
name.includes("email") ||
|
||||
label.includes("email") ||
|
||||
label.includes("이메일");
|
||||
|
||||
if (isEmail) {
|
||||
result.add(field.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isPhone =
|
||||
name.includes("phone") ||
|
||||
name.includes("tel") ||
|
||||
name.includes("mobile") ||
|
||||
label.includes("전화") ||
|
||||
label.includes("연락처") ||
|
||||
label.includes("휴대폰");
|
||||
|
||||
if (isPhone) {
|
||||
result.add(field.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isBirth =
|
||||
name.includes("birth") ||
|
||||
name.includes("birthday") ||
|
||||
name.includes("dob") ||
|
||||
label.includes("생년월일") ||
|
||||
label.includes("생일");
|
||||
|
||||
if (isBirth) {
|
||||
result.add(field.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
|
||||
const projectSlugRaw = formData.get("__projectSlug");
|
||||
let projectSlug: string | null = null;
|
||||
|
||||
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
||||
projectSlug = projectSlugRaw.trim();
|
||||
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
||||
const v = config.extraParams.projectSlug.trim();
|
||||
if (v !== "") {
|
||||
projectSlug = v;
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveNames = buildSensitiveFieldNameSet(config);
|
||||
|
||||
formData.forEach((_, key) => {
|
||||
if (key === "__config" || key === "__projectSlug") {
|
||||
return;
|
||||
}
|
||||
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
const isEmailField =
|
||||
lower.includes("email") ||
|
||||
lower.includes("e-mail") ||
|
||||
lower.includes("이메일");
|
||||
|
||||
const isPhoneField =
|
||||
lower.includes("phone") ||
|
||||
lower.includes("tel") ||
|
||||
lower.includes("mobile") ||
|
||||
lower.includes("전화") ||
|
||||
lower.includes("연락처") ||
|
||||
lower.includes("휴대폰");
|
||||
|
||||
const isBirthField =
|
||||
lower.includes("birth") ||
|
||||
lower.includes("birthday") ||
|
||||
lower.includes("birthdate") ||
|
||||
lower.includes("dob") ||
|
||||
lower.includes("생년월일") ||
|
||||
lower.includes("생일");
|
||||
|
||||
if (isEmailField || isPhoneField || isBirthField) {
|
||||
sensitiveNames.add(key);
|
||||
}
|
||||
});
|
||||
const payloadJson: Record<string, string> = {};
|
||||
const sensitivePayload: Record<string, string> = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (key === "__config" || key === "__projectSlug") {
|
||||
return;
|
||||
}
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
if (sensitiveNames.has(key)) {
|
||||
sensitivePayload[key] = strValue;
|
||||
} else {
|
||||
payloadJson[key] = strValue;
|
||||
}
|
||||
});
|
||||
|
||||
let sensitiveEnc: string | null = null;
|
||||
if (Object.keys(sensitivePayload).length > 0) {
|
||||
sensitiveEnc = await encryptJson(sensitivePayload);
|
||||
}
|
||||
|
||||
let projectId: string | null = null;
|
||||
let userId: string | null = null;
|
||||
|
||||
if (projectSlug) {
|
||||
const project = await prisma.project.findUnique({ where: { slug: projectSlug } });
|
||||
if (project) {
|
||||
projectId = project.id;
|
||||
userId = project.userId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.formSubmission.create({
|
||||
data: {
|
||||
projectSlug: projectSlug ?? "",
|
||||
projectId,
|
||||
userId,
|
||||
payloadJson,
|
||||
sensitiveEnc,
|
||||
metaJson: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const formData = await req.formData();
|
||||
|
||||
// 기본 필드(name/email/message)는 그대로 읽어둔다.
|
||||
const name = formData.get("name");
|
||||
const email = formData.get("email");
|
||||
const message = formData.get("message");
|
||||
|
||||
// 에디터에서 넘겨준 폼 설정(JSON) 읽기
|
||||
const rawConfig = formData.get("__config");
|
||||
let config: FormBlockProps | null = null;
|
||||
if (typeof rawConfig === "string") {
|
||||
@@ -24,18 +167,17 @@ export async function POST(req: Request) {
|
||||
const successMessage = config?.successMessage;
|
||||
const errorMessage = config?.errorMessage;
|
||||
|
||||
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
|
||||
if (submitTarget === "internal") {
|
||||
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
|
||||
await persistFormSubmission(formData, config);
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
return NextResponse.json({ ok: true, message: successMessage });
|
||||
}
|
||||
|
||||
if (submitTarget === "both") {
|
||||
await persistFormSubmission(formData, config);
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
}
|
||||
|
||||
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
||||
if (submitTarget === "webhook" || submitTarget === "both") {
|
||||
const destinationUrl = config?.destinationUrl;
|
||||
if (!destinationUrl) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken, decryptJson } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await context.params;
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
const submissions = await prisma.formSubmission.findMany({
|
||||
where: { projectId: project.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
const items = await Promise.all(
|
||||
submissions.map(async (s: any) => {
|
||||
const basePayload = (s.payloadJson ?? {}) as Record<string, unknown>;
|
||||
let sensitive: Record<string, unknown> = {};
|
||||
|
||||
if (s.sensitiveEnc) {
|
||||
try {
|
||||
sensitive = (await decryptJson<Record<string, unknown>>(s.sensitiveEnc)) ?? {};
|
||||
} catch {
|
||||
sensitive = {};
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { ...basePayload, ...sensitive };
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
projectSlug: s.projectSlug,
|
||||
payload,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(items, { status: 200 });
|
||||
}
|
||||
@@ -39,7 +39,22 @@ export default function PreviewPage() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const slug = (projectConfig as any)?.slug?.trim?.();
|
||||
let slug = (projectConfig as any)?.slug?.trim?.();
|
||||
|
||||
if (!slug) {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const fromQuery = url.searchParams.get("slug")?.trim();
|
||||
|
||||
if (fromQuery && fromQuery !== slug) {
|
||||
slug = fromQuery;
|
||||
updateProjectConfig({ slug: fromQuery } as any);
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
@@ -163,7 +178,10 @@ export default function PreviewPage() {
|
||||
className="w-full"
|
||||
style={canvasStyle}
|
||||
>
|
||||
<PublicPageRenderer blocks={blocks} />
|
||||
<PublicPageRenderer
|
||||
blocks={blocks}
|
||||
projectSlug={(projectConfig?.slug ?? "").trim() || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
|
||||
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
|
||||
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
|
||||
// - 401 이면 로그인 페이지로 이동시키고, 404/500 계열 에러는 한국어 에러 메시지로 안내한다.
|
||||
// - 응답 payload 의 name/email/phone/birthdate 는 개별 컬럼으로, 나머지는 "기타 필드" 텍스트로 렌더링한다.
|
||||
|
||||
interface SubmissionItem {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
projectSlug: string;
|
||||
// 서버에서 복호화된 폼 필드 전체가 payload 로 내려온다.
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PageStatus = "idle" | "loading" | "error";
|
||||
|
||||
export default function ProjectSubmissionsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams() as { slug?: string } | null;
|
||||
const slug = (params?.slug ?? "").toString();
|
||||
|
||||
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
||||
const [status, setStatus] = useState<PageStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다.
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
setErrorMessage(null);
|
||||
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/submissions`);
|
||||
|
||||
if (!res.ok) {
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.status === 401) {
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SubmissionItem[];
|
||||
|
||||
if (!cancelled) {
|
||||
setSubmissions(Array.isArray(data) ? data : []);
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
// payload 에서 name/email/phone/birthdate 를 제외한 나머지 필드를 "기타" 영역으로 모아 보여준다.
|
||||
const renderOtherFields = (payload: Record<string, unknown>): string => {
|
||||
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
|
||||
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => {
|
||||
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return `${key}: ${stringValue}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">폼 제출 내역</h1>
|
||||
<p className="text-xs text-slate-400">프로젝트에 연결된 폼으로 수집된 제출 데이터를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">
|
||||
<span className="font-mono text-[11px]">{slug}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
{status === "error" && errorMessage && (
|
||||
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="text-xs text-slate-400 mb-3">폼 제출 내역을 불러오는 중입니다...</p>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 && status === "idle" && !errorMessage && (
|
||||
<p className="text-xs text-slate-400">아직 수집된 폼 제출 내역이 없습니다.</p>
|
||||
)}
|
||||
|
||||
{submissions.length > 0 && (
|
||||
<table className="w-full text-xs text-left border-collapse mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-400">
|
||||
<th className="py-2 pr-4">제출 시각</th>
|
||||
<th className="py-2 pr-4">이름</th>
|
||||
<th className="py-2 pr-4">이메일</th>
|
||||
<th className="py-2 pr-4">전화번호</th>
|
||||
<th className="py-2 pr-4">생년월일</th>
|
||||
<th className="py-2 pr-4">기타 필드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((item) => {
|
||||
const payload = (item.payload ?? {}) as Record<string, unknown>;
|
||||
|
||||
const nameValue = payload["name"];
|
||||
const emailValue = payload["email"];
|
||||
const phoneValue = payload["phone"];
|
||||
const birthdateValue = payload["birthdate"];
|
||||
|
||||
const name = typeof nameValue === "string" ? nameValue : "";
|
||||
const email = typeof emailValue === "string" ? emailValue : "";
|
||||
const phone = typeof phoneValue === "string" ? phoneValue : "";
|
||||
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<td className="py-2 pr-4 text-slate-300 text-[11px]">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{name}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{email}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{phone}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{birthdate}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 whitespace-pre-wrap">
|
||||
{renderOtherFields(payload)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -330,6 +330,13 @@ export default function ProjectsPage() {
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>미리보기</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||||
className="inline-flex items-center gap-1 text-emerald-300 hover:text-emerald-100 underline-offset-2 hover:underline"
|
||||
>
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>폼 제출 내역</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
||||
|
||||
@@ -74,10 +74,10 @@ export async function signAccessToken(user: JwtUserLike): Promise<string> {
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
// 만료 시간은 기본 15분으로 설정한다.
|
||||
// 만료 시간은 기본 7일으로 설정한다.
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: "15m",
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
@@ -86,6 +86,7 @@ export function getSectionLayoutConfig(props: SectionBlockProps) {
|
||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
||||
interface PublicPageRendererProps {
|
||||
blocks: Block[];
|
||||
projectSlug?: string;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
@@ -99,7 +100,7 @@ const convertPxStringToEm = (value?: string | null) => {
|
||||
return pxToEm(px);
|
||||
};
|
||||
|
||||
export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
@@ -107,10 +108,6 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
const [formMessage, setFormMessage] = useState<string>("");
|
||||
|
||||
const renderBlock = (block: Block) => {
|
||||
if ((block as any).type === "form") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
const props = block.props as TextBlockProps;
|
||||
const tokens = computeTextPublicTokens(props);
|
||||
@@ -594,6 +591,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
>
|
||||
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
||||
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
||||
{projectSlug && projectSlug.trim() !== "" ? (
|
||||
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
|
||||
) : null}
|
||||
{hasFields && (
|
||||
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
||||
{fields.map((field: any) => (
|
||||
|
||||
Reference in New Issue
Block a user