diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index e14fd44..e96eab7 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -33,6 +33,9 @@ export default function EditorPage() { const addButtonBlock = useEditorStore((state) => state.addButtonBlock); const addImageBlock = useEditorStore((state) => state.addImageBlock); const addSectionBlock = useEditorStore((state) => state.addSectionBlock); + const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection); + const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection); + const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection); const updateBlock = useEditorStore((state) => state.updateBlock); const selectBlock = useEditorStore((state) => state.selectBlock); const replaceBlocks = useEditorStore((state) => state.replaceBlocks); @@ -253,6 +256,30 @@ export default function EditorPage() { > 섹션 블록 추가 +
+

템플릿

+ + + +

Text / Image / Button / Section 블록을 이 영역에서 추가할 수 있습니다.

diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index 5bca76d..23c047b 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -55,6 +55,9 @@ export interface EditorState { addButtonBlock: () => void; addImageBlock: () => void; addSectionBlock: () => void; + addHeroTemplateSection: () => void; + addFeaturesTemplateSection: () => void; + addCtaTemplateSection: () => void; updateBlock: (id: string, partial: Partial) => void; selectBlock: (id: string | null) => void; replaceBlocks: (blocks: Block[]) => void; @@ -112,6 +115,204 @@ const createEditorState = (set: any, get: any): EditorState => ({ })); }, + // Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다 + addHeroTemplateSection: () => { + const sectionId = createId(); + + // Hero 템플릿용 섹션: 기본 배경/패딩 + 1컬럼(12) + const sectionBlock: Block = { + id: sectionId, + type: "section", + props: { + background: "default", + paddingY: "lg", + columns: [ + { + id: `${sectionId}_col_1`, + span: 12, + }, + ], + }, + sectionId: null, + columnId: null, + }; + + const firstColumnId = `${sectionId}_col_1`; + + // Hero 텍스트 블록 (예: 헤드라인) + const heroHeadlineId = createId(); + const heroHeadline: Block = { + id: heroHeadlineId, + type: "text", + props: { + text: "Hero 제목을 여기에 입력하세요", + align: "center", + size: "lg", + }, + sectionId, + columnId: firstColumnId, + }; + + // Hero 서브텍스트 블록 + const heroSubId = createId(); + const heroSub: Block = { + id: heroSubId, + type: "text", + props: { + text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.", + align: "center", + size: "base", + }, + sectionId, + columnId: firstColumnId, + }; + + // CTA 버튼 블록 + const heroButtonId = createId(); + const heroButton: Block = { + id: heroButtonId, + type: "button", + props: { + label: "지금 시작하기", + href: "#", + }, + sectionId, + columnId: firstColumnId, + }; + + set((state: EditorState) => { + const newBlocks = [...state.blocks, sectionBlock, heroHeadline, heroSub, heroButton]; + + return { + blocks: newBlocks, + selectedBlockId: heroButtonId, + }; + }); + }, + + // Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다 + addFeaturesTemplateSection: () => { + 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: "md", + columns, + }, + sectionId: null, + columnId: null, + }; + + const featureBlocks: Block[] = columns.flatMap((col, index) => { + const titleId = createId(); + const descId = createId(); + + const title: Block = { + id: titleId, + type: "text", + props: { + text: `Feature ${index + 1} 제목`, + align: "left", + size: "lg", + }, + sectionId, + columnId: col.id, + }; + + const description: Block = { + id: descId, + type: "text", + props: { + text: "해당 기능을 간단히 설명하는 텍스트입니다.", + align: "left", + size: "sm", + }, + sectionId, + columnId: col.id, + }; + + return [title, description]; + }); + + const lastBlockId = featureBlocks[featureBlocks.length - 1].id; + + set((state: EditorState) => { + const newBlocks = [...state.blocks, sectionBlock, ...featureBlocks]; + + return { + blocks: newBlocks, + selectedBlockId: lastBlockId, + }; + }); + }, + + // CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다 + addCtaTemplateSection: () => { + const sectionId = createId(); + + const sectionBlock: Block = { + id: sectionId, + type: "section", + props: { + background: "primary", + paddingY: "md", + columns: [ + { + id: `${sectionId}_col_1`, + span: 12, + }, + ], + }, + sectionId: null, + columnId: null, + }; + + const firstColumnId = `${sectionId}_col_1`; + + const textId = createId(); + const textBlock: Block = { + id: textId, + type: "text", + props: { + text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.", + align: "center", + size: "base", + }, + sectionId, + columnId: firstColumnId, + }; + + const buttonId = createId(); + const buttonBlock: Block = { + id: buttonId, + type: "button", + props: { + label: "CTA 버튼", + href: "#", + }, + sectionId, + columnId: firstColumnId, + }; + + set((state: EditorState) => { + const newBlocks = [...state.blocks, sectionBlock, textBlock, buttonBlock]; + + return { + blocks: newBlocks, + selectedBlockId: buttonId, + }; + }); + }, + // 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다 addImageBlock: () => { const id = createId(); diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts index 0db44e5..e05888c 100644 --- a/tests/e2e/editor.spec.ts +++ b/tests/e2e/editor.spec.ts @@ -291,3 +291,48 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면 // 왼쪽 컬럼에는 더 이상 블록이 없어야 한다. await expect(leftColumn.getByTestId("editor-block")).toHaveCount(0); }); + +test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼 블록이 캔버스에 생성되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + // Hero 템플릿을 추가한다. + await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); + + // 섹션/블록이 하나 이상 존재해야 한다. + const blocks = canvas.getByTestId("editor-block"); + await expect(blocks.first()).toBeVisible(); + + // Hero 헤드라인 텍스트가 포함된 블록이 보여야 한다. + await expect(canvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible(); + + // CTA 버튼도 렌더되어 있어야 한다. + const ctaButton = canvas.getByRole("button", { name: "지금 시작하기" }); + await expect(ctaButton).toBeVisible(); +}); + +test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/설명)이 생성되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + await page.getByRole("button", { name: "Features 템플릿 추가" }).click(); + + // Feature 제목들이 렌더되어야 한다. + await expect(canvas.getByText("Feature 1 제목")).toBeVisible(); + await expect(canvas.getByText("Feature 2 제목")).toBeVisible(); + await expect(canvas.getByText("Feature 3 제목")).toBeVisible(); +}); + +test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 생성되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + await page.getByRole("button", { name: "CTA 템플릿 추가" }).click(); + + await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible(); + const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" }); + await expect(ctaButton).toBeVisible(); +}); diff --git a/tests/unit/editorStore.spec.ts b/tests/unit/editorStore.spec.ts index 9100755..bc1c30f 100644 --- a/tests/unit/editorStore.spec.ts +++ b/tests/unit/editorStore.spec.ts @@ -174,4 +174,112 @@ describe("editorStore", () => { expect(moved.sectionId).toBe(sectionBlock.id); expect(moved.columnId).toBe(updatedColumns[1].id); }); + + it("Hero 템플릿 섹션을 추가하면 섹션과 기본 텍스트/버튼 블록들이 한번에 생성되어야 한다", () => { + const store = createEditorStore(); + + // 템플릿 액션을 호출한다고 가정한다. + // Hero 템플릿은 기본적으로 1개의 섹션과 최소 1개의 텍스트/1개의 버튼 블록을 생성한다고 정의한다. + // (구체적인 props 내용은 구현에서 맞춰가되, 타입/개수/섹션/컬럼 배치만 검증한다.) + store.getState().addHeroTemplateSection(); + + const { blocks, selectedBlockId } = store.getState(); + + // 섹션 1개 + 텍스트/버튼 블록이 최소 1개씩 존재해야 한다. + const sectionBlocks = blocks.filter((b) => b.type === "section"); + const textBlocks = blocks.filter((b) => b.type === "text"); + const buttonBlocks = blocks.filter((b) => b.type === "button"); + + expect(sectionBlocks).toHaveLength(1); + expect(textBlocks.length).toBeGreaterThanOrEqual(1); + expect(buttonBlocks.length).toBeGreaterThanOrEqual(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; + + textBlocks.forEach((tb: any) => { + expect(tb.sectionId).toBe(section.id); + expect(tb.columnId).toBe(firstColumnId); + }); + + buttonBlocks.forEach((bb: any) => { + expect(bb.sectionId).toBe(section.id); + expect(bb.columnId).toBe(firstColumnId); + }); + + // 마지막으로 생성된 블록(예: CTA 버튼)이 선택되어 있다고 가정한다. + expect(selectedBlockId).toBe(blocks[blocks.length - 1].id); + }); + + it("Features 템플릿 섹션을 추가하면 3컬럼 섹션과 각 컬럼의 제목/설명 텍스트가 생성되어야 한다", () => { + const store = createEditorStore(); + + // Features 템플릿 액션을 호출한다고 가정한다. + store.getState().addFeaturesTemplateSection(); + + 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개의 feature 항목(제목/설명)을 가정하고, 각 컬럼에는 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("CTA 템플릿 섹션을 추가하면 텍스트와 버튼이 포함된 섹션이 생성되어야 한다", () => { + const store = createEditorStore(); + + // CTA 템플릿 액션을 호출한다고 가정한다. + store.getState().addCtaTemplateSection(); + + 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[]; + const buttonBlocks = blocks.filter((b) => b.type === "button") as any[]; + + expect(textBlocks.length).toBeGreaterThanOrEqual(1); + expect(buttonBlocks.length).toBeGreaterThanOrEqual(1); + + textBlocks.forEach((tb) => { + expect(tb.sectionId).toBe(section.id); + expect(tb.columnId).toBe(firstColumnId); + }); + + buttonBlocks.forEach((bb) => { + expect(bb.sectionId).toBe(section.id); + expect(bb.columnId).toBe(firstColumnId); + }); + + expect(selectedBlockId).toBe(blocks[blocks.length - 1].id); + }); });