From 243083261fbdb20d254223b8564d66e05a47351f Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 7 Dec 2025 19:11:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=ED=8F=BC=EC=A0=84=EC=86=A1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/export/route.ts | 13 + src/app/editor/forms/FormControllerPanel.tsx | 102 ++++++- src/app/editor/page.tsx | 115 +++++++ src/app/p/[slug]/PublicProjectPageClient.tsx | 114 +++++++ src/app/p/[slug]/page.tsx | 3 +- src/app/projects/page.tsx | 2 +- src/styles/builder.css | 1 + tests/api/export.spec.ts | 47 ++- tests/e2e/form-submission-flow.spec.ts | 103 +++++++ .../EditorNewProjectFromProjects.spec.tsx | 155 ++++++++++ tests/unit/EditorServerAutosave.spec.tsx | 289 ++++++++++++++++++ tests/unit/FormControllerPanel.spec.tsx | 71 +++++ uploads/32cdb1ef-47d0-4ded-a3c9-e83f6ae7df56 | 1 + uploads/a0bf0df5-fc1d-4b19-bda0-1faf55bf98b3 | 1 + uploads/d01747fb-6cb1-47ee-886b-5c2709cc7b53 | 1 + uploads/dca1af8c-f113-414b-bd63-e3f4468cb632 | 1 + uploads/e7aeaeb6-268b-4e5d-baf3-8ba3638e4ee3 | 1 + uploads/test-image-1765085372285 | 1 + uploads/test-image-1765087428870 | 1 + uploads/test-section-bg-1765085372291 | 1 + uploads/test-section-bg-1765087428876 | 1 + uploads/test-section-bg-video-1765085372312 | 1 + uploads/test-section-bg-video-1765087428888 | 1 + uploads/test-video-1765085372310 | 1 + uploads/test-video-1765087428887 | 1 + uploads/test-video-poster-1765085372288 | 1 + uploads/test-video-poster-1765087428873 | 1 + 27 files changed, 1025 insertions(+), 5 deletions(-) create mode 100644 src/app/p/[slug]/PublicProjectPageClient.tsx create mode 100644 tests/unit/EditorNewProjectFromProjects.spec.tsx create mode 100644 tests/unit/EditorServerAutosave.spec.tsx create mode 100644 uploads/32cdb1ef-47d0-4ded-a3c9-e83f6ae7df56 create mode 100644 uploads/a0bf0df5-fc1d-4b19-bda0-1faf55bf98b3 create mode 100644 uploads/d01747fb-6cb1-47ee-886b-5c2709cc7b53 create mode 100644 uploads/dca1af8c-f113-414b-bd63-e3f4468cb632 create mode 100644 uploads/e7aeaeb6-268b-4e5d-baf3-8ba3638e4ee3 create mode 100644 uploads/test-image-1765085372285 create mode 100644 uploads/test-image-1765087428870 create mode 100644 uploads/test-section-bg-1765085372291 create mode 100644 uploads/test-section-bg-1765087428876 create mode 100644 uploads/test-section-bg-video-1765085372312 create mode 100644 uploads/test-section-bg-video-1765087428888 create mode 100644 uploads/test-video-1765085372310 create mode 100644 uploads/test-video-1765087428887 create mode 100644 uploads/test-video-poster-1765085372288 create mode 100644 uploads/test-video-poster-1765087428873 diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 7643f4e..5d26b85 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -165,6 +165,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): // - formBlockIdToFormDomId: key = FormBlock id, value =
요소의 DOM id const fieldIdToFormId = new Map(); const formBlockIdToFormDomId = new Map(); + const submitButtonIdToFormDomId = new Map(); const requiredFieldIdSet = new Set(); // 테스트 및 기존 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 = `${imgAlt}${innerHtml}`; } + const submitFormId = submitButtonIdToFormDomId.get(block.id); + if (submitFormId) { + return `
`; + } + return ``; diff --git a/src/app/editor/forms/FormControllerPanel.tsx b/src/app/editor/forms/FormControllerPanel.tsx index 4c03d62..d02e9af 100644 --- a/src/app/editor/forms/FormControllerPanel.tsx +++ b/src/app/editor/forms/FormControllerPanel.tsx @@ -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 (

@@ -40,9 +93,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc

{formProps.submitTarget === "webhook" && ( -
+
+ +
+ +
)}
@@ -325,6 +388,41 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc + + {showSheetsGuide && ( +
+
+
+

Google Sheets 연동 가이드

+ +
+

+ 구글 시트 주소를 그대로 입력하는 것이 아니라, 시트에 연결된 Google Apps Script 웹 앱 URL 을 입력해야 합니다. + 아래 순서를 따르면 코드를 잘 모르는 사용자도 연동할 수 있습니다. +

+
    +
  1. Google Sheets 에서 새 스프레드시트를 만들고, 1행에 name, email, message 같은 헤더를 추가합니다.
  2. +
  3. 시트 메뉴의 "확장 프로그램 → 앱스 스크립트" 를 열고 아래 예제 코드를 붙여넣습니다.
  4. +
  5. "배포 → 새 배포" 에서 웹 앱으로 배포한 뒤, 발급된 웹 앱 URL(…/exec) 을 Webhook URL 칸에 붙여넣습니다.
  6. +
+
+ 예시 Apps Script 코드 +