텍스트 스타일 및 프리뷰 렌더링 정리
CI / test (push) Successful in 11m5s
CI / pr_and_merge (push) Successful in 1m40s
CI / test (pull_request) Failing after 35m42s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-20 23:36:47 +09:00
parent 9e40ee405c
commit 2f59e3781e
19 changed files with 3831 additions and 556 deletions
+362 -7
View File
@@ -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();