텍스트 스타일 및 프리뷰 렌더링 정리
This commit is contained in:
@@ -31,6 +31,8 @@ export type ColorPickerFieldProps = {
|
||||
|
||||
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
|
||||
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
|
||||
// 투명
|
||||
{ id: "transparent", label: "투명", color: "transparent" },
|
||||
// 기본/중립 계열
|
||||
{ id: "default", label: "기본", color: "#e5e7eb" },
|
||||
{ id: "muted", label: "연한", color: "#9ca3af" },
|
||||
@@ -72,6 +74,8 @@ export function ColorPickerField({
|
||||
onPaletteSelect,
|
||||
}: ColorPickerFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const colorInputValue = value && value.startsWith("#") ? value : "#ffffff";
|
||||
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
|
||||
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.value);
|
||||
@@ -91,20 +95,26 @@ export function ColorPickerField({
|
||||
};
|
||||
|
||||
// 현재 선택된 팔레트 정보를 계산한다.
|
||||
const selectedPalette =
|
||||
selectedPaletteId && palette.length > 0
|
||||
? palette.find((item) => item.id === selectedPaletteId) ?? null
|
||||
: null;
|
||||
// 1) selectedPaletteId 가 넘어오면 ID 기준으로 찾고,
|
||||
// 2) 없으면 현재 value 와 color 가 일치하는 팔레트 항목을 사용한다.
|
||||
let selectedPalette: ColorPaletteItem | null = null;
|
||||
if (palette.length > 0) {
|
||||
if (selectedPaletteId) {
|
||||
selectedPalette = palette.find((item) => item.id === selectedPaletteId) ?? null;
|
||||
} else if (value) {
|
||||
selectedPalette = palette.find((item) => item.color === value) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={ariaLabelColorInput}
|
||||
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
|
||||
value={value || "#ffffff"}
|
||||
value={colorInputValue}
|
||||
onChange={handleColorInputChange}
|
||||
/>
|
||||
<input
|
||||
@@ -143,7 +153,8 @@ export function ColorPickerField({
|
||||
? "bg-sky-900/40 text-sky-100"
|
||||
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handlePaletteClick(item);
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -162,6 +173,6 @@ export function ColorPickerField({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import type {
|
||||
Block,
|
||||
TextBlockProps,
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
ImageBlockProps,
|
||||
SectionBlockProps,
|
||||
FormBlockProps,
|
||||
FormSelectOption,
|
||||
FormRadioOption,
|
||||
FormCheckboxOption,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
||||
@@ -26,14 +29,48 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
if (block.type === "text") {
|
||||
const props = block.props as TextBlockProps;
|
||||
|
||||
// 정렬: 에디터와 동일하게 left/center/right 를 그대로 사용하되, 퍼블릭 뷰에서는 Tailwind text-* 클래스로 매핑한다.
|
||||
const alignClass =
|
||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
||||
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
|
||||
|
||||
// 폰트 크기: 에디터와 동일한 규칙(fontSizeMode/Scale + size fallback)을 사용하되,
|
||||
// 퍼블릭 뷰에서는 Tailwind text-* 유틸리티 또는 style.fontSize 로 매핑한다.
|
||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
||||
|
||||
const fontSizeScaleToClass: Record<
|
||||
NonNullable<TextBlockProps["fontSizeScale"]>,
|
||||
string
|
||||
> = {
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-lg",
|
||||
xl: "text-xl",
|
||||
"2xl": "text-2xl",
|
||||
"3xl": "text-3xl",
|
||||
};
|
||||
|
||||
let sizeClass = "";
|
||||
const textStyle: CSSProperties = {};
|
||||
|
||||
if (fontSizeMode === "scale") {
|
||||
sizeClass = fontSizeScaleToClass[fontSizeScale];
|
||||
} else if (fontSizeMode === "custom" && props.fontSizeCustom) {
|
||||
textStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
|
||||
// 텍스트 색상: 에디터에서 설정한 colorCustom 이 있으면 그대로 사용하고, 없으면 기본 text-slate-50 클래스를 사용한다.
|
||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
||||
textStyle.color = props.colorCustom;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={block.id}
|
||||
className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
||||
style={textStyle}
|
||||
>
|
||||
{props.text}
|
||||
</p>
|
||||
@@ -57,6 +94,96 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
if (block.type === "form") {
|
||||
const props = block.props as FormBlockProps;
|
||||
|
||||
// v2: fieldIds 가 설정되어 있으면 폼 요소 블록(formInput/formSelect/formCheckbox/formRadio)을 기반으로 필드를 구성한다.
|
||||
const hasControllerFields = props.fieldIds && props.fieldIds.length > 0;
|
||||
|
||||
const controllerFields = hasControllerFields
|
||||
? (props.fieldIds ?? [])
|
||||
.map((fieldId) => blocks.find((b) => b.id === fieldId))
|
||||
.filter((b): b is Block => Boolean(b))
|
||||
.filter(
|
||||
(b) =>
|
||||
b.type === "formInput" ||
|
||||
b.type === "formSelect" ||
|
||||
b.type === "formCheckbox" ||
|
||||
b.type === "formRadio",
|
||||
)
|
||||
.map((fieldBlock) => {
|
||||
const anyProps = fieldBlock.props as any;
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.label ?? anyProps.formFieldName ?? "입력 필드",
|
||||
type: "text" as const,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldBlock.type === "formSelect") {
|
||||
const options: FormSelectOption[] = Array.isArray(anyProps.options)
|
||||
? anyProps.options
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.label ?? anyProps.formFieldName ?? "선택 필드",
|
||||
type: "select" as const,
|
||||
options,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldBlock.type === "formCheckbox") {
|
||||
const options: FormCheckboxOption[] = Array.isArray(anyProps.options)
|
||||
? anyProps.options
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.groupLabel ?? anyProps.formFieldName ?? "체크박스",
|
||||
type: "checkbox" as const,
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
}
|
||||
|
||||
// formRadio
|
||||
const options: FormRadioOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.groupLabel ?? anyProps.formFieldName ?? "라디오 그룹",
|
||||
type: "radio" as const,
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const fields = hasControllerFields && controllerFields.length > 0
|
||||
? controllerFields
|
||||
: props.fields && props.fields.length > 0
|
||||
? props.fields
|
||||
: [
|
||||
{ id: `${block.id}_name`, name: "name", label: "이름", type: "text", required: true },
|
||||
{ id: `${block.id}_email`, name: "email", label: "이메일", type: "email", required: true },
|
||||
{ id: `${block.id}_message`, name: "message", label: "메시지", type: "textarea", required: true },
|
||||
];
|
||||
|
||||
// FormBlock 이 submitButtonId 를 가지고 있다면, 해당 버튼 블록의 label 을 submit 버튼 라벨로 사용한다.
|
||||
const mappedSubmitButton = blocks.find(
|
||||
(b) => b.type === "button" && b.id === props.submitButtonId,
|
||||
);
|
||||
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -89,35 +216,112 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
|
||||
return (
|
||||
<form key={block.id} className="space-y-3" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>이름</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name="name"
|
||||
placeholder="이름을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>이메일</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>메시지</span>
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name="message"
|
||||
placeholder="전달할 내용을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
||||
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
||||
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
||||
{fields.map((field: any) => (
|
||||
<label key={field.id} className="flex flex-col gap-1">
|
||||
<span>{field.label}</span>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
placeholder={field.label}
|
||||
required={field.required}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
defaultValue={field.options?.[0]?.value ?? ""}
|
||||
>
|
||||
{(field.options ?? []).map((opt: FormSelectOption) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === "checkbox" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 그룹 타이틀: 텍스트/이미지 모드를 지원한다. alt 는 그룹 라벨 텍스트를 사용한다. */}
|
||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={field.groupLabelImageUrl}
|
||||
alt={field.label}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{field.label}</span>
|
||||
)}
|
||||
{(field.options ?? []).map((opt: FormCheckboxOption, index: number) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
type="checkbox"
|
||||
name={field.name}
|
||||
value={opt.value}
|
||||
required={field.required && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={opt.labelImageUrl}
|
||||
alt={opt.label || field.label || "체크박스 옵션"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{opt.label}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : field.type === "radio" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={field.groupLabelImageUrl}
|
||||
alt={field.label}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{field.label}</span>
|
||||
)}
|
||||
{(field.options ?? []).map((opt: FormRadioOption, index: number) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={field.name}
|
||||
value={opt.value}
|
||||
required={field.required && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={opt.labelImageUrl}
|
||||
alt={opt.label || field.label || "라디오 옵션"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{opt.label}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
type={field.type === "email" ? "email" : "text"}
|
||||
placeholder={field.label}
|
||||
required={field.required}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -125,7 +329,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
disabled={formStatus === "submitting"}
|
||||
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{formStatus === "submitting" ? "전송 중..." : "폼 전송"}
|
||||
{formStatus === "submitting"
|
||||
? "전송 중..."
|
||||
: mappedSubmitLabel ?? "폼 전송"}
|
||||
</button>
|
||||
|
||||
{formStatus !== "idle" && formMessage ? (
|
||||
|
||||
@@ -10,8 +10,19 @@ import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
|
||||
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
|
||||
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
|
||||
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록
|
||||
export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list" | "form";
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
||||
export type BlockType =
|
||||
| "text"
|
||||
| "button"
|
||||
| "image"
|
||||
| "section"
|
||||
| "divider"
|
||||
| "list"
|
||||
| "form"
|
||||
| "formInput"
|
||||
| "formSelect"
|
||||
| "formCheckbox"
|
||||
| "formRadio";
|
||||
|
||||
// 텍스트 블록 속성
|
||||
export interface TextBlockProps {
|
||||
@@ -129,13 +140,124 @@ export interface SectionBlockProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
// 폼 블록 속성 (1차 MVP: 고정 contact 폼)
|
||||
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
|
||||
export interface FormFieldStyleProps {
|
||||
align?: "left" | "center" | "right";
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
fullWidth?: boolean;
|
||||
// 필드 너비 및 레이아웃
|
||||
widthMode?: "auto" | "full" | "fixed";
|
||||
widthPx?: number;
|
||||
labelLayout?: "stacked" | "inline";
|
||||
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
// 필드 텍스트 색상/타이포그라피
|
||||
fontSizeCustom?: string;
|
||||
lineHeightCustom?: string;
|
||||
letterSpacingCustom?: string;
|
||||
textColorCustom?: string;
|
||||
// 필드 배경/테두리 색상
|
||||
fillColorCustom?: string;
|
||||
strokeColorCustom?: string;
|
||||
}
|
||||
|
||||
// 폼 입력 블록 속성
|
||||
export type FormLabelMode = "text" | "image";
|
||||
|
||||
export interface FormInputBlockProps extends FormFieldStyleProps {
|
||||
label: string;
|
||||
formFieldName: string;
|
||||
required?: boolean;
|
||||
inputType?: "text" | "email" | "textarea";
|
||||
placeholder?: string;
|
||||
labelMode?: FormLabelMode;
|
||||
labelImageUrl?: string;
|
||||
labelImageAlt?: string;
|
||||
}
|
||||
|
||||
// 폼 셀렉트 옵션/블록 속성
|
||||
export interface FormSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FormSelectBlockProps extends FormFieldStyleProps {
|
||||
label: string;
|
||||
formFieldName: string;
|
||||
options: FormSelectOption[];
|
||||
required?: boolean;
|
||||
labelMode?: FormLabelMode;
|
||||
labelImageUrl?: string;
|
||||
labelImageAlt?: string;
|
||||
}
|
||||
|
||||
// 폼 체크박스 옵션/블록 속성 (여러 체크박스를 한 그룹으로 표현)
|
||||
export interface FormCheckboxOption {
|
||||
label: string;
|
||||
value: string;
|
||||
// 각 체크박스 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다.
|
||||
labelImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface FormCheckboxBlockProps extends FormFieldStyleProps {
|
||||
groupLabel: string;
|
||||
formFieldName: string;
|
||||
options: FormCheckboxOption[];
|
||||
required?: boolean;
|
||||
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
|
||||
groupLabelMode?: FormLabelMode;
|
||||
groupLabelImageUrl?: string;
|
||||
}
|
||||
|
||||
// 폼 라디오 그룹 옵션/블록 속성
|
||||
export interface FormRadioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
// 각 라디오 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다.
|
||||
labelImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface FormRadioBlockProps extends FormFieldStyleProps {
|
||||
groupLabel: string;
|
||||
formFieldName: string;
|
||||
options: FormRadioOption[];
|
||||
required?: boolean;
|
||||
// 라디오 그룹 타이틀의 텍스트/이미지 모드
|
||||
groupLabelMode?: FormLabelMode;
|
||||
groupLabelImageUrl?: string;
|
||||
}
|
||||
|
||||
// 폼 필드 설정
|
||||
export interface FormFieldConfig {
|
||||
id: string;
|
||||
name: string; // 실제 전송 키 (예: "email")
|
||||
label: string; // UI 라벨 (예: "이메일")
|
||||
type: "text" | "email" | "textarea";
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// 폼 블록 속성
|
||||
export type FormSubmitTarget = "internal" | "webhook";
|
||||
export type FormPayloadFormat = "form" | "json";
|
||||
|
||||
export interface FormBlockProps {
|
||||
kind: "contact";
|
||||
// 전송 대상: 현재는 internal 만 지원, 이후 webhook 등 확장 가능
|
||||
submitTarget: "internal";
|
||||
// 전송 대상: internal 또는 webhook(외부 URL, Google Sheets 포함)
|
||||
submitTarget: FormSubmitTarget;
|
||||
// 성공/실패 메시지
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
// webhook 설정 (Google Sheets 포함)
|
||||
destinationUrl?: string; // POST 보낼 URL
|
||||
method?: "POST" | "GET"; // 기본 POST
|
||||
// webhook 전송 포맷: 기본은 x-www-form-urlencoded(form).
|
||||
payloadFormat?: FormPayloadFormat;
|
||||
headers?: Record<string, string>; // Authorization 등
|
||||
extraParams?: Record<string, string>; // 항상 함께 보내는 추가 파라미터(formId 등)
|
||||
// 폼 필드 목록
|
||||
fields?: FormFieldConfig[];
|
||||
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
|
||||
fieldIds?: string[];
|
||||
submitButtonId?: string | null;
|
||||
}
|
||||
|
||||
// 공통 블록 모델
|
||||
@@ -149,7 +271,11 @@ export interface Block {
|
||||
| SectionBlockProps
|
||||
| DividerBlockProps
|
||||
| ListBlockProps
|
||||
| FormBlockProps;
|
||||
| FormBlockProps
|
||||
| FormInputBlockProps
|
||||
| FormSelectBlockProps
|
||||
| FormCheckboxBlockProps
|
||||
| FormRadioBlockProps;
|
||||
// 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null)
|
||||
sectionId?: string | null;
|
||||
columnId?: string | null;
|
||||
@@ -168,6 +294,10 @@ export interface EditorState {
|
||||
addListBlock: () => void;
|
||||
addSectionBlock: () => void;
|
||||
addFormBlock: () => void;
|
||||
addFormInputBlock: () => void;
|
||||
addFormSelectBlock: () => void;
|
||||
addFormCheckboxBlock: () => void;
|
||||
addFormRadioBlock: () => void;
|
||||
addHeroTemplateSection: () => void;
|
||||
addFeaturesTemplateSection: () => void;
|
||||
addCtaTemplateSection: () => void;
|
||||
@@ -179,7 +309,14 @@ export interface EditorState {
|
||||
addFooterTemplateSection: () => void;
|
||||
updateBlock: (
|
||||
id: string,
|
||||
partial: Partial<TextBlockProps> | Partial<ButtonBlockProps>,
|
||||
partial:
|
||||
| Partial<TextBlockProps>
|
||||
| Partial<ButtonBlockProps>
|
||||
| Partial<FormBlockProps>
|
||||
| Partial<FormInputBlockProps>
|
||||
| Partial<FormSelectBlockProps>
|
||||
| Partial<FormCheckboxBlockProps>
|
||||
| Partial<FormRadioBlockProps>,
|
||||
) => void;
|
||||
selectBlock: (id: string | null) => void;
|
||||
replaceBlocks: (blocks: Block[]) => void;
|
||||
@@ -195,6 +332,25 @@ export interface EditorState {
|
||||
let idCounter = 0;
|
||||
const createId = () => `blk_${Date.now()}_${idCounter++}`;
|
||||
|
||||
const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
||||
const regex = new RegExp(`^${prefix}-(\\d+)$`);
|
||||
let max = 0;
|
||||
|
||||
for (const b of blocks) {
|
||||
const anyProps: any = b.props;
|
||||
const name = anyProps?.formFieldName;
|
||||
if (typeof name !== "string") continue;
|
||||
const match = name.match(regex);
|
||||
if (!match) continue;
|
||||
const n = Number(match[1] ?? 0);
|
||||
if (!Number.isNaN(n) && n > max) {
|
||||
max = n;
|
||||
}
|
||||
}
|
||||
|
||||
return `${prefix}-${max + 1}`;
|
||||
};
|
||||
|
||||
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
||||
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
||||
const createEditorState = (set: any, get: any): EditorState => ({
|
||||
@@ -449,6 +605,155 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
|
||||
addFormInputBlock: () => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const label = "입력 필드";
|
||||
const formFieldName = getNextFormFieldName(blocks, "text");
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "formInput",
|
||||
props: {
|
||||
label,
|
||||
formFieldName,
|
||||
required: false,
|
||||
inputType: "text",
|
||||
placeholder: "",
|
||||
labelMode: "text",
|
||||
borderRadius: "md",
|
||||
fillColorCustom: "",
|
||||
strokeColorCustom: "",
|
||||
textColorCustom: "",
|
||||
widthMode: "full",
|
||||
labelLayout: "stacked",
|
||||
},
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 셀렉트 블록 추가: 루트에 기본 label/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
||||
addFormSelectBlock: () => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const label = "선택 필드";
|
||||
const formFieldName = getNextFormFieldName(blocks, "select");
|
||||
const options: FormSelectOption[] = [
|
||||
{ label: "옵션 1", value: "option_1" },
|
||||
{ label: "옵션 2", value: "option_2" },
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label,
|
||||
formFieldName,
|
||||
options,
|
||||
required: false,
|
||||
labelMode: "text",
|
||||
borderRadius: "md",
|
||||
fillColorCustom: "",
|
||||
strokeColorCustom: "",
|
||||
textColorCustom: "",
|
||||
widthMode: "full",
|
||||
labelLayout: "stacked",
|
||||
},
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 체크박스 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
||||
addFormCheckboxBlock: () => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const groupLabel = "체크박스";
|
||||
const formFieldName = getNextFormFieldName(blocks, "checkbox");
|
||||
const options: FormCheckboxOption[] = [
|
||||
{ label: "체크 1", value: "check_1" },
|
||||
{ label: "체크 2", value: "check_2" },
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel,
|
||||
formFieldName,
|
||||
options,
|
||||
required: false,
|
||||
groupLabelMode: "text",
|
||||
borderRadius: "md",
|
||||
fillColorCustom: "",
|
||||
strokeColorCustom: "",
|
||||
textColorCustom: "",
|
||||
widthMode: "full",
|
||||
labelLayout: "stacked",
|
||||
},
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 라디오 그룹 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
||||
addFormRadioBlock: () => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const groupLabel = "라디오 그룹";
|
||||
const formFieldName = getNextFormFieldName(blocks, "radio");
|
||||
const options: FormRadioOption[] = [
|
||||
{ label: "옵션 A", value: "option_a" },
|
||||
{ label: "옵션 B", value: "option_b" },
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel,
|
||||
formFieldName,
|
||||
options,
|
||||
required: false,
|
||||
groupLabelMode: "text",
|
||||
borderRadius: "md",
|
||||
fillColorCustom: "",
|
||||
strokeColorCustom: "",
|
||||
textColorCustom: "",
|
||||
widthMode: "full",
|
||||
labelLayout: "stacked",
|
||||
},
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 구분선 블록 추가: 기본 정렬/두께와 함께 생성 후 선택 상태로 만든다
|
||||
addDividerBlock: () => {
|
||||
const id = createId();
|
||||
@@ -554,6 +859,56 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 블록 추가: 기본 contact 폼 설정과 함께 생성 후 선택 상태로 만든다
|
||||
addFormBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
const fields: FormFieldConfig[] = [
|
||||
{
|
||||
id: `${id}_field_name`,
|
||||
name: "name",
|
||||
label: "이름",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: `${id}_field_email`,
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: `${id}_field_message`,
|
||||
name: "message",
|
||||
label: "메시지",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "성공적으로 전송되었습니다.",
|
||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
||||
fields,
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
},
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
|
||||
addButtonBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
Reference in New Issue
Block a user