폼전송 기능 수정
CI / test (push) Failing after 4m53s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 19:11:06 +09:00
parent 96fa34cd86
commit 243083261f
27 changed files with 1025 additions and 5 deletions
+13
View File
@@ -165,6 +165,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
// - formBlockIdToFormDomId: key = FormBlock id, value = <form> 요소의 DOM id
const fieldIdToFormId = new Map<string, string>();
const formBlockIdToFormDomId = new Map<string, string>();
const submitButtonIdToFormDomId = new Map<string, string>();
const requiredFieldIdSet = new Set<string>();
// 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다.
// - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1)
@@ -190,6 +191,11 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
}
}
const submitButtonIdRaw = props.submitButtonId;
if (typeof submitButtonIdRaw === "string" && submitButtonIdRaw.trim() !== "") {
submitButtonIdToFormDomId.set(submitButtonIdRaw, formDomId);
}
const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
for (const fieldId of requiredFieldIds) {
if (typeof fieldId === "string" && fieldId.trim() !== "") {
@@ -284,6 +290,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
}
const submitFormId = submitButtonIdToFormDomId.get(block.id);
if (submitFormId) {
return `<div class="${tokens.alignClass}"><button type="submit" form="${escapeAttr(
submitFormId,
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</button></div>`;
}
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
href,
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
+100 -2
View File
@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { computeFormControllerPublicTokens } from "@/features/editor/utils/formHelpers";
interface FormControllerPanelProps {
block: Block; // type === "form"
@@ -13,8 +15,59 @@ interface FormControllerPanelProps {
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const [showSheetsGuide, setShowSheetsGuide] = useState(false);
const formProps = block.props as FormBlockProps;
const sheetsScriptExample = (() => {
const tokens = computeFormControllerPublicTokens(block, blocks);
const fieldNames = tokens.fields.map((f) => f.name).filter((name) => !!name);
if (fieldNames.length === 0) {
return (
"function doPost(e) {\n" +
' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");\n' +
" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }\n" +
" var params = e.parameter; // x-www-form-urlencoded\n" +
" var row = [\n" +
' params.name || "",\n' +
' params.email || "",\n' +
' params.message || "",\n' +
" new Date(),\n" +
" ];\n" +
" sheet.appendRow(row);\n" +
" return ContentService\n" +
" .createTextOutput(JSON.stringify({ ok: true }))\n" +
" .setMimeType(ContentService.MimeType.JSON);\n" +
"}"
);
}
const headerNames = [...fieldNames, "createdAt"];
const rowLines = fieldNames.map((name) => ` params.${name} || "",`);
rowLines.push(" new Date(),");
const lines: string[] = [];
lines.push("function doPost(e) {");
lines.push(' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");');
lines.push(" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }");
lines.push(" var params = e.parameter; // x-www-form-urlencoded");
lines.push(" // 시트의 1행 헤더는 다음과 같이 맞춰 주세요:");
lines.push(` // ${headerNames.join(", ")}`);
lines.push(" var row = [");
for (const line of rowLines) {
lines.push(line);
}
lines.push(" ];");
lines.push(" sheet.appendRow(row);");
lines.push(" return ContentService");
lines.push(" .createTextOutput(JSON.stringify({ ok: true }))");
lines.push(" .setMimeType(ContentService.MimeType.JSON);");
lines.push("}");
return lines.join("\n");
})();
return (
<div className="space-y-4 text-xs">
<p className="text-slate-400">
@@ -40,9 +93,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</div>
{formProps.submitTarget === "webhook" && (
<div className="space-y-2">
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-slate-400">Webhook / Google Sheets URL</span>
<span className="text-slate-400">Webhook URL (: Google Apps Script URL)</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
placeholder="예: https://script.google.com/macros/s/.../exec"
@@ -126,6 +179,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
}}
/>
</label>
<div className="flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] text-slate-100 hover:bg-slate-800"
onClick={() => setShowSheetsGuide(true)}
>
Google Sheets
</button>
</div>
</div>
)}
</div>
@@ -325,6 +388,41 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</select>
</div>
</div>
{showSheetsGuide && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70">
<div className="w-full max-w-xl rounded border border-slate-800 bg-slate-950 px-4 py-3 shadow-lg">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[11px] font-semibold text-slate-100">Google Sheets </h4>
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-100"
onClick={() => setShowSheetsGuide(false)}
aria-label="Google Sheets 연동 가이드 닫기"
>
</button>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
, Google Apps Script URL .
.
</p>
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
<li>Google Sheets , 1 name, email, message .</li>
<li> "확장 프로그램 → 앱스 스크립트" .</li>
<li>"배포 → 새 배포" , URL(/exec) Webhook URL .</li>
</ol>
<div className="mt-2 space-y-1">
<span className="text-[11px] text-slate-400"> Apps Script </span>
<textarea
className="w-full h-40 rounded border border-slate-800 bg-slate-950 px-2 py-1 text-[11px] font-mono text-slate-200"
readOnly
value={sheetsScriptExample}
/>
</div>
</div>
</div>
)}
</div>
);
}
+115
View File
@@ -150,6 +150,7 @@ function EditorPageInner() {
const [projectMessage, setProjectMessage] = useState("");
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const [initializedNewProjectFromQuery, setInitializedNewProjectFromQuery] = useState(false);
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
@@ -176,6 +177,75 @@ function EditorPageInner() {
}
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
useEffect(() => {
if (!searchParams) return;
if (initializedNewProjectFromQuery) return;
const newParam = searchParams.get("new");
if (newParam !== "1") return;
if (blocks.length > 0) {
replaceBlocks([]);
}
updateProjectConfig({
title: "새 페이지",
slug: "my-landing",
canvasPreset: "full",
canvasBgColorHex: "#020617",
bodyBgColorHex: "#020617",
headHtml: "",
trackingScript: "",
seoTitle: "",
seoDescription: "",
seoOgImageUrl: "",
seoCanonicalUrl: "",
seoNoIndex: false,
} as ProjectConfig);
resetHistory();
setInitializedNewProjectFromQuery(true);
}, [searchParams, initializedNewProjectFromQuery, blocks.length, replaceBlocks, updateProjectConfig, resetHistory]);
useEffect(() => {
if (!searchParams) return;
const slugParam = searchParams.get("slug");
if (slugParam && slugParam.trim().length > 0) {
// URL 에 slug 쿼리 파라미터가 있으면 기존 프로젝트 편집 중이므로,
// 자동 슬러그 생성은 동작시키지 않는다.
return;
}
const currentSlugRaw = projectConfig?.slug;
const currentSlug = typeof currentSlugRaw === "string" ? currentSlugRaw.trim() : "";
// 초기 상태("my-landing") 또는 비어 있는 경우에만 자동 슬러그를 한 번 생성한다.
if (currentSlug && currentSlug !== "my-landing") {
return;
}
const now = new Date();
const datePart = [
now.getFullYear().toString().padStart(4, "0"),
(now.getMonth() + 1).toString().padStart(2, "0"),
now.getDate().toString().padStart(2, "0"),
].join("");
const timePart = [
now.getHours().toString().padStart(2, "0"),
now.getMinutes().toString().padStart(2, "0"),
now.getSeconds().toString().padStart(2, "0"),
].join("");
const randomPart = Math.random().toString(36).slice(2, 6);
const generatedSlug = `page-${datePart}-${timePart}-${randomPart}`;
updateProjectConfig({ slug: generatedSlug });
}, [searchParams, projectConfig?.slug, updateProjectConfig]);
useEffect(() => {
if (!authChecked) return;
if (!initialSlugFromQuery) return;
@@ -817,6 +887,51 @@ function EditorPageInner() {
}
}, [blocks, projectConfig, authUserId, authChecked]);
useEffect(() => {
if (!authChecked) return;
if (!authUserId) return;
if (typeof window === "undefined") return;
const slugRaw = projectConfig?.slug;
const slug = typeof slugRaw === "string" ? slugRaw.trim() : "";
if (!slug || slug === "my-landing") return;
const titleRaw = projectConfig?.title;
const title = (typeof titleRaw === "string" ? titleRaw.trim() : "") || "제목 없음";
const intervalMs = 20000;
let cancelled = false;
const saveOnce = async () => {
if (cancelled) return;
try {
await fetch("/api/projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
slug,
contentJson: blocks,
}),
});
} catch {
// 서버 자동 저장 실패는 치명적이지 않으므로 조용히 무시한다.
}
};
const id = window.setInterval(() => {
void saveOnce();
}, intervalMs);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [authChecked, authUserId, projectConfig?.slug, projectConfig?.title, blocks]);
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
interface PublicProjectPageClientProps {
html: string;
}
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
const [toastMessage, setToastMessage] = useState<string | null>(null);
useEffect(() => {
if (typeof document === "undefined" || typeof window === "undefined") return;
let toastTimeout: number | null = null;
const showToast = (message: string) => {
setToastMessage(message);
if (toastTimeout !== null) {
window.clearTimeout(toastTimeout);
}
toastTimeout = window.setTimeout(() => {
setToastMessage(null);
}, 4000);
};
const forms = document.querySelectorAll<HTMLFormElement>(
'form.pb-form-controller, form[action="/api/forms/submit"]',
);
if (!forms || forms.length === 0) return;
forms.forEach((form) => {
if (form.dataset.pbInitialized === "1") return;
form.dataset.pbInitialized = "1";
form.addEventListener("submit", (event) => {
if (!form) return;
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
const formData = new FormData(form);
const configInput = form.querySelector<HTMLInputElement>('input[name="__config"]');
let config: any = null;
if (configInput && configInput.value) {
try {
config = JSON.parse(configInput.value);
} catch {
config = null;
}
}
const action = form.getAttribute("action") || "/api/forms/submit";
fetch(action, { method: "POST", body: formData })
.then((res) => res.json().catch(() => ({})))
.then((data: any) => {
const ok = data && data.ok;
const message = data && data.message;
if (ok) {
const success =
message || (config && config.successMessage) || "성공적으로 전송되었습니다.";
showToast(success);
try {
form.reset();
} catch {}
} else {
const errorMsg =
message || (config && config.errorMessage) || "전송 중 오류가 발생했습니다.";
showToast(errorMsg);
}
})
.catch(() => {
const errorMsg =
(config && config.errorMessage) || "전송 중 오류가 발생했습니다.";
showToast(errorMsg);
});
});
});
return () => {
if (toastTimeout !== null) {
window.clearTimeout(toastTimeout);
}
};
}, []);
return (
<>
<div dangerouslySetInnerHTML={{ __html: html }} />
{toastMessage ? (
<div
style={{
position: "fixed",
right: "16px",
bottom: "16px",
zIndex: 50,
maxWidth: "320px",
padding: "12px 16px",
borderRadius: "8px",
backgroundColor: "rgba(15, 23, 42, 0.95)",
color: "#e5e7eb",
fontSize: "14px",
lineHeight: "1.4",
boxShadow: "0 10px 25px rgba(15, 23, 42, 0.6)",
pointerEvents: "none",
}}
>
{toastMessage}
</div>
) : null}
</>
);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { PrismaClient } from "@prisma/client";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
import { getCachedPublicProject } from "@/features/projects/publicCache";
import PublicProjectPageClient from "./PublicProjectPageClient";
const prisma = new PrismaClient();
@@ -75,5 +76,5 @@ export default async function PublicProjectPage({ params }: PageParams) {
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
const mainHtml = mainMatch ? mainMatch[0] : "";
return <div dangerouslySetInnerHTML={{ __html: mainHtml }} />;
return <PublicProjectPageClient html={mainHtml} />;
}
+1 -1
View File
@@ -220,7 +220,7 @@ export default function ProjectsPage() {
<span> </span>
</Link>
<Link
href="/editor"
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
>
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
+1
View File
@@ -60,6 +60,7 @@ body {
.pb-section-inner {
padding-inline: 1.5rem;
margin-inline: auto;
}
.pb-section-columns {