diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx
index 9b57de3..5e1ae54 100644
--- a/src/app/editor/page.tsx
+++ b/src/app/editor/page.tsx
@@ -37,6 +37,9 @@ export default function EditorPage() {
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
+ const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection);
+ const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection);
+ const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection);
const updateBlock = useEditorStore((state) => state.updateBlock);
const selectBlock = useEditorStore((state) => state.selectBlock);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
@@ -373,6 +376,27 @@ export default function EditorPage() {
>
CTA 템플릿 추가
+
+
+
Text / Image / Button / Section 블록을 이 영역에서 추가할 수 있습니다.
diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts
index 8124cac..a3aa1e4 100644
--- a/src/features/editor/state/editorStore.ts
+++ b/src/features/editor/state/editorStore.ts
@@ -60,6 +60,9 @@ export interface EditorState {
addHeroTemplateSection: () => void;
addFeaturesTemplateSection: () => void;
addCtaTemplateSection: () => void;
+ addFaqTemplateSection: () => void;
+ addPricingTemplateSection: () => void;
+ addTestimonialsTemplateSection: () => void;
updateBlock: (id: string, partial: Partial) => void;
selectBlock: (id: string | null) => void;
replaceBlocks: (blocks: Block[]) => void;
@@ -323,6 +326,233 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
+ // FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
+ addFaqTemplateSection: () => {
+ const sectionId = createId();
+
+ const sectionBlock: Block = {
+ id: sectionId,
+ type: "section",
+ props: {
+ background: "default",
+ paddingY: "md",
+ columns: [
+ {
+ id: `${sectionId}_col_1`,
+ span: 12,
+ },
+ ],
+ },
+ sectionId: null,
+ columnId: null,
+ };
+
+ const firstColumnId = `${sectionId}_col_1`;
+
+ const faqPairs = [
+ {
+ question: "자주 묻는 질문 1",
+ answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
+ },
+ {
+ question: "자주 묻는 질문 2",
+ answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
+ },
+ {
+ question: "자주 묻는 질문 3",
+ answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
+ },
+ ];
+
+ const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
+ const qId = createId();
+ const aId = createId();
+
+ const questionBlock: Block = {
+ id: qId,
+ type: "text",
+ props: {
+ text: pair.question,
+ align: "left",
+ size: "lg",
+ },
+ sectionId,
+ columnId: firstColumnId,
+ };
+
+ const answerBlock: Block = {
+ id: aId,
+ type: "text",
+ props: {
+ text: pair.answer,
+ align: "left",
+ size: "sm",
+ },
+ sectionId,
+ columnId: firstColumnId,
+ };
+
+ return [questionBlock, answerBlock];
+ });
+
+ const lastBlockId = faqBlocks[faqBlocks.length - 1].id;
+
+ set((state: EditorState) => {
+ const newBlocks = [...state.blocks, sectionBlock, ...faqBlocks];
+
+ return {
+ blocks: newBlocks,
+ selectedBlockId: lastBlockId,
+ };
+ });
+ },
+
+ // Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
+ addPricingTemplateSection: () => {
+ const sectionId = createId();
+
+ const columns = [
+ { id: `${sectionId}_col_1`, span: 4 },
+ { id: `${sectionId}_col_2`, span: 4 },
+ { id: `${sectionId}_col_3`, span: 4 },
+ ];
+
+ const sectionBlock: Block = {
+ id: sectionId,
+ type: "section",
+ props: {
+ background: "muted",
+ paddingY: "lg",
+ columns,
+ },
+ sectionId: null,
+ columnId: null,
+ };
+
+ const planDefinitions = [
+ { name: "Basic", price: "₩9,900/월" },
+ { name: "Pro", price: "₩29,900/월" },
+ { name: "Enterprise", price: "문의" },
+ ];
+
+ const pricingBlocks: Block[] = columns.flatMap((col, index) => {
+ const plan = planDefinitions[index] ?? planDefinitions[0];
+
+ const nameId = createId();
+ const priceId = createId();
+
+ const nameBlock: Block = {
+ id: nameId,
+ type: "text",
+ props: {
+ text: plan.name,
+ align: "center",
+ size: "lg",
+ },
+ sectionId,
+ columnId: col.id,
+ };
+
+ const priceBlock: Block = {
+ id: priceId,
+ type: "text",
+ props: {
+ text: plan.price,
+ align: "center",
+ size: "base",
+ },
+ sectionId,
+ columnId: col.id,
+ };
+
+ return [nameBlock, priceBlock];
+ });
+
+ const lastBlockId = pricingBlocks[pricingBlocks.length - 1].id;
+
+ set((state: EditorState) => {
+ const newBlocks = [...state.blocks, sectionBlock, ...pricingBlocks];
+
+ return {
+ blocks: newBlocks,
+ selectedBlockId: lastBlockId,
+ };
+ });
+ },
+
+ // Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
+ addTestimonialsTemplateSection: () => {
+ const sectionId = createId();
+
+ const columns = [
+ { id: `${sectionId}_col_1`, span: 4 },
+ { id: `${sectionId}_col_2`, span: 4 },
+ { id: `${sectionId}_col_3`, span: 4 },
+ ];
+
+ const sectionBlock: Block = {
+ id: sectionId,
+ type: "section",
+ props: {
+ background: "default",
+ paddingY: "lg",
+ columns,
+ },
+ sectionId: null,
+ columnId: null,
+ };
+
+ const testimonialDefinitions = [
+ { body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
+ { body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
+ { body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
+ ];
+
+ const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
+ const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
+
+ const bodyId = createId();
+ const authorId = createId();
+
+ const bodyBlock: Block = {
+ id: bodyId,
+ type: "text",
+ props: {
+ text: t.body,
+ align: "left",
+ size: "base",
+ },
+ sectionId,
+ columnId: col.id,
+ };
+
+ const authorBlock: Block = {
+ id: authorId,
+ type: "text",
+ props: {
+ text: `- ${t.author}`,
+ align: "left",
+ size: "sm",
+ },
+ sectionId,
+ columnId: col.id,
+ };
+
+ return [bodyBlock, authorBlock];
+ });
+
+ const lastBlockId = testimonialBlocks[testimonialBlocks.length - 1].id;
+
+ set((state: EditorState) => {
+ const newBlocks = [...state.blocks, sectionBlock, ...testimonialBlocks];
+
+ return {
+ blocks: newBlocks,
+ selectedBlockId: lastBlockId,
+ };
+ });
+ },
+
// 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다
addImageBlock: () => {
const id = createId();
diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts
index 98a6da7..e9d4784 100644
--- a/tests/e2e/editor.spec.ts
+++ b/tests/e2e/editor.spec.ts
@@ -337,6 +337,47 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
await expect(ctaButton).toBeVisible();
});
+test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍 이상 생성되어야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ const canvas = page.getByTestId("editor-canvas");
+
+ await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click();
+
+ // 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
+ await expect(canvas.getByText("자주 묻는 질문 1")).toBeVisible();
+ await expect(canvas.getByText("첫 번째 질문에 대한 답변을 여기에 입력하세요.")).toBeVisible();
+ await expect(canvas.getByText("자주 묻는 질문 2")).toBeVisible();
+ await expect(canvas.getByText("자주 묻는 질문 3")).toBeVisible();
+});
+
+test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생성되어야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ const canvas = page.getByTestId("editor-canvas");
+
+ await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
+
+ await expect(canvas.getByText("Basic")).toBeVisible();
+ await expect(canvas.getByText("₩9,900/월")).toBeVisible();
+ await expect(canvas.getByText("Pro")).toBeVisible();
+ await expect(canvas.getByText("₩29,900/월")).toBeVisible();
+ await expect(canvas.getByText("Enterprise")).toBeVisible();
+ await expect(canvas.getByText("문의")).toBeVisible();
+});
+
+test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생성되어야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ const canvas = page.getByTestId("editor-canvas");
+
+ await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
+
+ await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
+ await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible();
+ await expect(canvas.getByText("팀 전체가 만족하는 빌더입니다.")).toBeVisible();
+});
+
test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
diff --git a/tests/unit/editorStore.spec.ts b/tests/unit/editorStore.spec.ts
index da3c432..b689092 100644
--- a/tests/unit/editorStore.spec.ts
+++ b/tests/unit/editorStore.spec.ts
@@ -283,6 +283,92 @@ describe("editorStore", () => {
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
});
+ it("FAQ 템플릿 섹션을 추가하면 질문/답변 쌍이 포함된 섹션이 생성되어야 한다", () => {
+ const store = createEditorStore();
+
+ store.getState().addFaqTemplateSection();
+
+ const { blocks, selectedBlockId } = store.getState();
+
+ const sectionBlocks = blocks.filter((b) => b.type === "section");
+ expect(sectionBlocks).toHaveLength(1);
+
+ const section = sectionBlocks[0] as any;
+ const columns = (section.props as any).columns;
+ expect(Array.isArray(columns)).toBe(true);
+ expect(columns.length).toBeGreaterThanOrEqual(1);
+
+ const firstColumnId = columns[0].id;
+
+ const textBlocks = blocks.filter((b) => b.type === "text") as any[];
+
+ // 최소 3개의 FAQ 항목(질문+답변 쌍)을 가정한다.
+ expect(textBlocks.length).toBeGreaterThanOrEqual(6);
+
+ textBlocks.forEach((tb) => {
+ expect(tb.sectionId).toBe(section.id);
+ expect(tb.columnId).toBe(firstColumnId);
+ });
+
+ expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
+ });
+
+ it("Pricing 템플릿 섹션을 추가하면 요금제 카드(플랜 이름/가격/설명)가 3개 생성되어야 한다", () => {
+ const store = createEditorStore();
+
+ store.getState().addPricingTemplateSection();
+
+ const { blocks, selectedBlockId } = store.getState();
+
+ const sectionBlocks = blocks.filter((b) => b.type === "section");
+ expect(sectionBlocks).toHaveLength(1);
+
+ const section = sectionBlocks[0] as any;
+ const columns = (section.props as any).columns;
+ expect(Array.isArray(columns)).toBe(true);
+ expect(columns.length).toBe(3);
+
+ const textBlocks = blocks.filter((b) => b.type === "text") as any[];
+
+ // 3개의 요금제에 대해 각 컬럼에 최소 2개 텍스트(플랜 이름 + 가격/설명)를 가정한다.
+ expect(textBlocks.length).toBeGreaterThanOrEqual(6);
+
+ textBlocks.forEach((tb) => {
+ expect(tb.sectionId).toBe(section.id);
+ expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
+ });
+
+ expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
+ });
+
+ it("Testimonials 템플릿 섹션을 추가하면 후기(본문/작성자)가 3개 생성되어야 한다", () => {
+ const store = createEditorStore();
+
+ store.getState().addTestimonialsTemplateSection();
+
+ const { blocks, selectedBlockId } = store.getState();
+
+ const sectionBlocks = blocks.filter((b) => b.type === "section");
+ expect(sectionBlocks).toHaveLength(1);
+
+ const section = sectionBlocks[0] as any;
+ const columns = (section.props as any).columns;
+ expect(Array.isArray(columns)).toBe(true);
+ expect(columns.length).toBe(3);
+
+ const textBlocks = blocks.filter((b) => b.type === "text") as any[];
+
+ // 3개의 후기 카드에 대해 각 컬럼에 최소 2개 텍스트(본문 + 작성자)를 가정한다.
+ expect(textBlocks.length).toBeGreaterThanOrEqual(6);
+
+ textBlocks.forEach((tb) => {
+ expect(tb.sectionId).toBe(section.id);
+ expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
+ });
+
+ expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
+ });
+
it("undo 호출 시 마지막 변경 이전의 블록 상태로 되돌려야 한다", () => {
const store = createEditorStore();