435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
"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";
|
||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||
import { getEditorFormControllerPanelMessages } from "@/features/i18n/messages/editorFormControllerPanel";
|
||
|
||
interface FormControllerPanelProps {
|
||
block: Block; // type === "form"
|
||
blocks: Block[];
|
||
selectedBlockId: string | null;
|
||
updateBlock: (id: string, partial: any) => void;
|
||
}
|
||
|
||
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 locale = useAppLocale();
|
||
const m = getEditorFormControllerPanelMessages(locale);
|
||
|
||
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">
|
||
{m.introTextPrefix}
|
||
<code className="font-mono text-[11px]">/api/forms/submit</code>
|
||
{m.introTextSuffix}
|
||
</p>
|
||
|
||
<div className="space-y-2">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.submitTargetLabel}</span>
|
||
<select
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
value={formProps.submitTarget ?? "internal"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
submitTarget: e.target.value as FormBlockProps["submitTarget"],
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="internal">{m.submitTargetOptionInternal}</option>
|
||
<option value="webhook">{m.submitTargetOptionWebhook}</option>
|
||
</select>
|
||
</label>
|
||
|
||
{formProps.submitTarget === "webhook" && (
|
||
<div className="space-y-3">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.webhookUrlLabel}</span>
|
||
<input
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
placeholder={m.webhookUrlPlaceholder}
|
||
value={formProps.destinationUrl ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
destinationUrl: e.target.value,
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="flex items-center justify-between gap-2">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.payloadFormatLabel}</span>
|
||
<select
|
||
className="w-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
value={formProps.payloadFormat ?? "form"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
payloadFormat: e.target.value as any,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="form">{m.payloadFormatOptionForm}</option>
|
||
<option value="json">{m.payloadFormatOptionJson}</option>
|
||
</select>
|
||
</label>
|
||
<label className="flex items-center justify-between gap-2">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.httpMethodLabel}</span>
|
||
<select
|
||
className="w-28 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
value={formProps.method ?? "POST"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
method: e.target.value as FormBlockProps["method"],
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="POST">POST</option>
|
||
<option value="GET">GET</option>
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.authTokenLabel}</span>
|
||
<input
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
placeholder={m.authTokenPlaceholder}
|
||
value={formProps.headers?.Authorization ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
headers: {
|
||
...(formProps.headers ?? {}),
|
||
Authorization: e.target.value,
|
||
},
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-slate-500 dark:text-slate-400">{m.extraParamsLabel}</span>
|
||
<textarea
|
||
className="w-full h-16 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
placeholder={m.extraParamsPlaceholder}
|
||
value={Object.entries(formProps.extraParams ?? {})
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join("\n")}
|
||
onChange={(e) => {
|
||
const lines = e.target.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||
const next: Record<string, string> = {};
|
||
for (const line of lines) {
|
||
const idx = line.indexOf("=");
|
||
if (idx > 0) {
|
||
const key = line.slice(0, idx).trim();
|
||
const value = line.slice(idx + 1).trim();
|
||
if (key) next[key] = value;
|
||
}
|
||
}
|
||
|
||
updateBlock(selectedBlockId, {
|
||
extraParams: next,
|
||
} as any);
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||
onClick={() => setShowSheetsGuide(true)}
|
||
>
|
||
{m.sheetsGuideButtonLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.layoutSectionTitle}</h3>
|
||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||
<span>{m.formWidthModeLabel}</span>
|
||
<select
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
value={formProps.formWidthMode ?? "auto"}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
formWidthMode: e.target.value as FormBlockProps["formWidthMode"],
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="auto">{m.formWidthModeOptionAuto}</option>
|
||
<option value="full">{m.formWidthModeOptionFull}</option>
|
||
<option value="fixed">{m.formWidthModeOptionFixed}</option>
|
||
</select>
|
||
</label>
|
||
|
||
{(formProps.formWidthMode ?? "auto") === "fixed" && (
|
||
<NumericPropertyControl
|
||
label={m.formFixedWidthLabel}
|
||
unitLabel="(px)"
|
||
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
|
||
min={160}
|
||
max={1200}
|
||
step={10}
|
||
presets={[
|
||
{ id: "sm", label: m.formFixedWidthPresetNarrow, value: 320 },
|
||
{ id: "md", label: m.formFixedWidthPresetNormal, value: 480 },
|
||
{ id: "lg", label: m.formFixedWidthPresetWide, value: 640 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
formWidthPx: v,
|
||
} as any)
|
||
}
|
||
/>
|
||
)}
|
||
|
||
<NumericPropertyControl
|
||
label={m.marginYLabel}
|
||
unitLabel="(px)"
|
||
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
|
||
min={0}
|
||
max={160}
|
||
step={4}
|
||
presets={[
|
||
{ id: "none", label: m.marginYPresetNone, value: 0 },
|
||
{ id: "compact", label: m.marginYPresetCompact, value: 16 },
|
||
{ id: "normal", label: m.marginYPresetNormal, value: 24 },
|
||
{ id: "spacious", label: m.marginYPresetSpacious, value: 40 },
|
||
]}
|
||
onChangeValue={(v) =>
|
||
updateBlock(selectedBlockId, {
|
||
marginYPx: v,
|
||
} as any)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.messagesSectionTitle}</h3>
|
||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||
<span>{m.successMessageLabel}</span>
|
||
<input
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
placeholder={m.successMessagePlaceholder}
|
||
value={formProps.successMessage ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
successMessage: e.target.value,
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||
<span>{m.errorMessageLabel}</span>
|
||
<input
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
placeholder={m.errorMessagePlaceholder}
|
||
value={formProps.errorMessage ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
errorMessage: e.target.value,
|
||
} as any)
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
||
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.controllerSectionTitle}</h3>
|
||
|
||
<fieldset className="space-y-2" aria-label={m.fieldMappingAriaLabel}>
|
||
<legend className="text-[11px] text-slate-400 mb-1">{m.fieldMappingLegend}</legend>
|
||
{blocks
|
||
.filter((b) =>
|
||
b.type === "formInput" ||
|
||
b.type === "formSelect" ||
|
||
b.type === "formCheckbox" ||
|
||
b.type === "formRadio",
|
||
)
|
||
.map((fieldBlock) => {
|
||
const fieldId = fieldBlock.id;
|
||
const anyProps: any = fieldBlock.props ?? {};
|
||
|
||
const transmissionKey: string | undefined = anyProps.formFieldName;
|
||
const labelText: string | undefined = anyProps.label ?? anyProps.groupLabel;
|
||
|
||
const displayLabel = transmissionKey
|
||
? labelText && labelText !== transmissionKey
|
||
? `${transmissionKey} (${labelText})`
|
||
: transmissionKey
|
||
: labelText || fieldId;
|
||
|
||
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
||
const required = (formProps.requiredFieldIds ?? []).includes(fieldId);
|
||
|
||
return (
|
||
<label
|
||
key={fieldId}
|
||
className="flex items-center gap-2 text-[11px] text-slate-800 dark:text-slate-200"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
|
||
checked={checked}
|
||
onChange={(e) => {
|
||
const current = formProps.fieldIds ?? [];
|
||
const next = e.target.checked
|
||
? [...current, fieldId]
|
||
: current.filter((id) => id !== fieldId);
|
||
|
||
updateBlock(selectedBlockId, {
|
||
fieldIds: next,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<span className="flex-1 truncate">{displayLabel}</span>
|
||
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||
<input
|
||
type="checkbox"
|
||
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
|
||
checked={required}
|
||
onChange={(e) => {
|
||
const current = formProps.requiredFieldIds ?? [];
|
||
const next = e.target.checked
|
||
? Array.from(new Set([...current, fieldId]))
|
||
: current.filter((id) => id !== fieldId);
|
||
|
||
updateBlock(selectedBlockId, {
|
||
requiredFieldIds: next,
|
||
} as any);
|
||
}}
|
||
/>
|
||
<span>{m.requiredLabel}</span>
|
||
</label>
|
||
</label>
|
||
);
|
||
})}
|
||
</fieldset>
|
||
|
||
<div className="space-y-1">
|
||
<span className="text-[11px] text-slate-400">{m.submitButtonLabel}</span>
|
||
<select
|
||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
aria-label={m.submitButtonAriaLabel}
|
||
value={formProps.submitButtonId ?? ""}
|
||
onChange={(e) =>
|
||
updateBlock(selectedBlockId, {
|
||
submitButtonId: e.target.value || null,
|
||
} as any)
|
||
}
|
||
>
|
||
<option value="">{m.submitButtonNoneOptionLabel}</option>
|
||
{blocks
|
||
.filter((b) => b.type === "button")
|
||
.map((buttonBlock) => {
|
||
const btnProps = buttonBlock.props as ButtonBlockProps;
|
||
const transmissionKey = btnProps.formFieldName;
|
||
const labelText = btnProps.label;
|
||
const displayLabel = transmissionKey
|
||
? labelText && labelText !== transmissionKey
|
||
? `${transmissionKey} (${labelText})`
|
||
: transmissionKey
|
||
: labelText || buttonBlock.id;
|
||
|
||
return (
|
||
<option key={buttonBlock.id} value={buttonBlock.id}>
|
||
{displayLabel}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{showSheetsGuide && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||
<div className="w-full max-w-xl rounded border border-slate-200 bg-white px-4 py-3 text-xs text-slate-900 shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h4 className="text-[11px] font-semibold">{m.sheetsGuideHeading}</h4>
|
||
<button
|
||
type="button"
|
||
className="text-[11px] text-slate-400 hover:text-slate-100"
|
||
onClick={() => setShowSheetsGuide(false)}
|
||
aria-label={m.sheetsGuideCloseAriaLabel}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
|
||
{m.sheetsGuideIntro}
|
||
</p>
|
||
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
|
||
<li>{m.sheetsGuideStep1}</li>
|
||
<li>{m.sheetsGuideStep2}</li>
|
||
<li>{m.sheetsGuideStep3}</li>
|
||
</ol>
|
||
<div className="mt-2 space-y-1">
|
||
<span className="text-[11px] text-slate-500 dark:text-slate-400">{m.sheetsGuideExampleCodeLabel}</span>
|
||
<textarea
|
||
className="w-full h-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||
readOnly
|
||
value={sheetsScriptExample}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|