238 lines
9.8 KiB
TypeScript
238 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
|
|
|
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 formProps = block.props as FormBlockProps;
|
|
|
|
return (
|
|
<div className="space-y-4 text-xs">
|
|
<p className="text-slate-400">
|
|
이 폼은 퍼블릭 페이지에서 <code className="font-mono text-[11px]">/api/forms/submit</code>
|
|
엔드포인트로 먼저 전송된 뒤, 설정에 따라 내부 처리 또는 Webhook 으로 전달됩니다.
|
|
</p>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-slate-400">전송 대상</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
value={formProps.submitTarget ?? "internal"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
submitTarget: e.target.value as FormBlockProps["submitTarget"],
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="internal">서버 처리 전용 (Webhook 없이 사용)</option>
|
|
<option value="webhook">외부 Webhook / Google Sheets 로 전달</option>
|
|
</select>
|
|
</div>
|
|
|
|
{formProps.submitTarget === "webhook" && (
|
|
<div className="space-y-2">
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-slate-400">Webhook / Google Sheets 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"
|
|
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-400">전송 포맷</span>
|
|
<select
|
|
className="w-40 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
value={formProps.payloadFormat ?? "form"}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
payloadFormat: e.target.value as any,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="form">폼 데이터 (x-www-form-urlencoded)</option>
|
|
<option value="json">JSON (application/json)</option>
|
|
</select>
|
|
</label>
|
|
<label className="flex items-center justify-between gap-2">
|
|
<span className="text-slate-400">HTTP 메서드</span>
|
|
<select
|
|
className="w-28 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
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-400">Authorization 토큰 (옵션)</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="예: Bearer xxxxxx"
|
|
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-400">추가 파라미터 (key=value 형식, 줄바꿈으로 구분)</span>
|
|
<textarea
|
|
className="w-full h-16 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500"
|
|
placeholder={"예:\nsource=landing\nformId=contact-hero"}
|
|
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>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
|
<h3 className="text-[11px] font-semibold text-slate-200">폼 메시지</h3>
|
|
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
<span>성공 메시지</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="예: 성공적으로 전송되었습니다."
|
|
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-400">
|
|
<span>에러 메시지</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="예: 전송 중 오류가 발생했습니다."
|
|
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-200">폼 컨트롤러</h3>
|
|
|
|
<fieldset className="space-y-2" aria-label="폼 필드 매핑">
|
|
<legend className="text-[11px] text-slate-400 mb-1">폼 필드 매핑</legend>
|
|
{blocks
|
|
.filter((b) =>
|
|
b.type === "formInput" ||
|
|
b.type === "formSelect" ||
|
|
b.type === "formCheckbox" ||
|
|
b.type === "formRadio",
|
|
)
|
|
.map((fieldBlock) => {
|
|
const fieldId = fieldBlock.id;
|
|
const fieldLabel = (fieldBlock.props as any).label ??
|
|
(fieldBlock.props as any).groupLabel ??
|
|
(fieldBlock.props as any).formFieldName ??
|
|
fieldId;
|
|
|
|
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
|
|
|
return (
|
|
<label
|
|
key={fieldId}
|
|
className="flex items-center gap-2 text-[11px] text-slate-200"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
|
|
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>{fieldLabel}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</fieldset>
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-[11px] text-slate-400">Submit 버튼</span>
|
|
<select
|
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
|
aria-label="Submit 버튼"
|
|
value={formProps.submitButtonId ?? ""}
|
|
onChange={(e) =>
|
|
updateBlock(selectedBlockId, {
|
|
submitButtonId: e.target.value || null,
|
|
} as any)
|
|
}
|
|
>
|
|
<option value="">선택 안 함</option>
|
|
{blocks
|
|
.filter((b) => b.type === "button")
|
|
.map((buttonBlock) => {
|
|
const btnProps = buttonBlock.props as ButtonBlockProps;
|
|
return (
|
|
<option key={buttonBlock.id} value={buttonBlock.id}>
|
|
{btnProps.label || buttonBlock.id}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|