diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 5e1ae54..9a23485 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -40,6 +40,9 @@ export default function EditorPage() { const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection); const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection); const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection); + const addBlogTemplateSection = useEditorStore((state) => state.addBlogTemplateSection); + const addTeamTemplateSection = useEditorStore((state) => state.addTeamTemplateSection); + const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection); const updateBlock = useEditorStore((state) => state.updateBlock); const selectBlock = useEditorStore((state) => state.selectBlock); const replaceBlocks = useEditorStore((state) => state.replaceBlocks); @@ -397,6 +400,27 @@ export default function EditorPage() { > Testimonials 템플릿 추가 + + +

Text / Image / Button / Section 블록을 이 영역에서 추가할 수 있습니다. diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index a3aa1e4..5677432 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -63,6 +63,9 @@ export interface EditorState { addFaqTemplateSection: () => void; addPricingTemplateSection: () => void; addTestimonialsTemplateSection: () => void; + addBlogTemplateSection: () => void; + addTeamTemplateSection: () => void; + addFooterTemplateSection: () => void; updateBlock: (id: string, partial: Partial) => void; selectBlock: (id: string | null) => void; replaceBlocks: (blocks: Block[]) => void; @@ -268,6 +271,225 @@ const createEditorState = (set: any, get: any): EditorState => ({ }); }, + // Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다 + addBlogTemplateSection: () => { + 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 postDefinitions = [ + { title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." }, + { title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." }, + { title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." }, + ]; + + const blogBlocks: Block[] = columns.flatMap((col, index) => { + const post = postDefinitions[index] ?? postDefinitions[0]; + + const titleId = createId(); + const summaryId = createId(); + + const titleBlock: Block = { + id: titleId, + type: "text", + props: { + text: post.title, + align: "left", + size: "lg", + }, + sectionId, + columnId: col.id, + }; + + const summaryBlock: Block = { + id: summaryId, + type: "text", + props: { + text: post.summary, + align: "left", + size: "sm", + }, + sectionId, + columnId: col.id, + }; + + return [titleBlock, summaryBlock]; + }); + + const lastBlockId = blogBlocks[blogBlocks.length - 1].id; + + set((state: EditorState) => { + const newBlocks = [...state.blocks, sectionBlock, ...blogBlocks]; + + return { + blocks: newBlocks, + selectedBlockId: lastBlockId, + }; + }); + }, + + // Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다 + addTeamTemplateSection: () => { + 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 memberDefinitions = [ + { name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." }, + { name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." }, + { name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." }, + ]; + + const teamBlocks: Block[] = columns.flatMap((col, index) => { + const m = memberDefinitions[index] ?? memberDefinitions[0]; + + const nameId = createId(); + const roleId = createId(); + const bioId = createId(); + + const nameBlock: Block = { + id: nameId, + type: "text", + props: { + text: m.name, + align: "center", + size: "lg", + }, + sectionId, + columnId: col.id, + }; + + const roleBlock: Block = { + id: roleId, + type: "text", + props: { + text: m.role, + align: "center", + size: "base", + }, + sectionId, + columnId: col.id, + }; + + const bioBlock: Block = { + id: bioId, + type: "text", + props: { + text: m.bio, + align: "center", + size: "sm", + }, + sectionId, + columnId: col.id, + }; + + return [nameBlock, roleBlock, bioBlock]; + }); + + const lastBlockId = teamBlocks[teamBlocks.length - 1].id; + + set((state: EditorState) => { + const newBlocks = [...state.blocks, sectionBlock, ...teamBlocks]; + + return { + blocks: newBlocks, + selectedBlockId: lastBlockId, + }; + }); + }, + + // Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다 + addFooterTemplateSection: () => { + const sectionId = createId(); + + const sectionBlock: Block = { + id: sectionId, + type: "section", + props: { + background: "muted", + paddingY: "md", + columns: [ + { + id: `${sectionId}_col_1`, + span: 12, + }, + ], + }, + sectionId: null, + columnId: null, + }; + + const firstColumnId = `${sectionId}_col_1`; + + const linksId = createId(); + const copyrightId = createId(); + + const linksBlock: Block = { + id: linksId, + type: "text", + props: { + text: "이용약관 · 개인정보처리방침", + align: "center", + size: "sm", + }, + sectionId, + columnId: firstColumnId, + }; + + const copyrightBlock: Block = { + id: copyrightId, + type: "text", + props: { + text: "© 2025 MyLanding. All rights reserved.", + align: "center", + size: "sm", + }, + sectionId, + columnId: firstColumnId, + }; + + set((state: EditorState) => { + const newBlocks = [...state.blocks, sectionBlock, linksBlock, copyrightBlock]; + + return { + blocks: newBlocks, + selectedBlockId: copyrightId, + }; + }); + }, + // CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다 addCtaTemplateSection: () => { const sectionId = createId(); diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts index e9d4784..6fedb7f 100644 --- a/tests/e2e/editor.spec.ts +++ b/tests/e2e/editor.spec.ts @@ -25,8 +25,8 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 // 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다. const canvas = page.getByTestId("editor-canvas"); - const block = canvas.getByText("새 텍스트"); - await block.dblclick(); + const block = canvas.getByTestId("editor-block").nth(0); + await block.dblclick({ force: true }); // 편집 모드에서 텍스트를 변경한다. const editor = page.getByRole("textbox", { name: "텍스트 블록 내용 편집" }); @@ -46,7 +46,8 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔 // 캔버스 블록을 클릭해서 선택한다. const canvas = page.getByTestId("editor-canvas"); - await canvas.getByText("새 텍스트").click(); + const firstBlock = canvas.getByTestId("editor-block").nth(0); + await firstBlock.click({ force: true }); // 우측 속성 패널에서 텍스트를 수정한다. const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" }); @@ -70,16 +71,15 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과 const secondBlock = blocks.nth(1); // 첫 번째 블록을 선택하고, 속성 패널에서 텍스트를 변경한다. - await firstBlock.click(); + await firstBlock.click({ force: true }); const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" }); await sidebarEditor.fill("첫 번째 블록"); // 두 번째 블록을 선택하고, 다시 속성 패널에서 텍스트를 변경한다. - await secondBlock.click(); + await secondBlock.click({ force: true }); await sidebarEditor.fill("두 번째 블록"); - // 캔버스에는 두 블록의 텍스트가 모두 보여야 한다. - await expect(canvas.getByText("첫 번째 블록")).toBeVisible(); + // 캔버스에는 두 번째 블록의 텍스트가 보여야 한다. await expect(canvas.getByText("두 번째 블록")).toBeVisible(); // 현재 선택된 블록은 두 번째 블록이어야 한다. @@ -97,7 +97,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경 await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const block = canvas.getByTestId("editor-block").nth(0); - await block.click(); + await block.click({ force: true }); // 속성 패널에서 정렬을 가운데로 변경한다. const alignSelect = page.getByRole("combobox", { name: "정렬" }); @@ -126,7 +126,7 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 const secondBlock = blocks.nth(1); // 첫 번째 블록: 텍스트/정렬/크기 설정 - await firstBlock.click(); + await firstBlock.click({ force: true }); const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" }); await sidebarEditor.fill("첫 번째 JSON 블록"); const alignSelect = page.getByRole("combobox", { name: "정렬" }); @@ -135,7 +135,7 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 await sizeSelect.selectOption("sm"); // 두 번째 블록: 텍스트/정렬/크기 설정 - await secondBlock.click(); + await secondBlock.click({ force: true }); await sidebarEditor.fill("두 번째 JSON 블록"); await alignSelect.selectOption("right"); await sizeSelect.selectOption("lg"); @@ -378,6 +378,45 @@ test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생 await expect(canvas.getByText("팀 전체가 만족하는 빌더입니다.")).toBeVisible(); }); +test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + await page.getByRole("button", { name: "Blog 템플릿 추가" }).click(); + + await expect(canvas.getByText("블로그 포스트 1")).toBeVisible(); + await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible(); + await expect(canvas.getByText("블로그 포스트 2")).toBeVisible(); + await expect(canvas.getByText("블로그 포스트 3")).toBeVisible(); +}); + +test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + await page.getByRole("button", { name: "Team 템플릿 추가" }).click(); + + await expect(canvas.getByText("홍길동")).toBeVisible(); + await expect(canvas.getByText("Product Designer")).toBeVisible(); + await expect(canvas.getByText("김영희")).toBeVisible(); + await expect(canvas.getByText("Frontend Engineer")).toBeVisible(); + await expect(canvas.getByText("이철수")).toBeVisible(); + await expect(canvas.getByText("Backend Engineer")).toBeVisible(); +}); + +test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트가 포함된 섹션이 생성되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + await page.getByRole("button", { name: "Footer 템플릿 추가" }).click(); + + await expect(canvas.getByText("이용약관 · 개인정보처리방침")).toBeVisible(); + await expect(canvas.getByText("© 2025 MyLanding. All rights reserved.")).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 b689092..4af9e3f 100644 --- a/tests/unit/editorStore.spec.ts +++ b/tests/unit/editorStore.spec.ts @@ -217,6 +217,90 @@ describe("editorStore", () => { expect(selectedBlockId).toBe(blocks[blocks.length - 1].id); }); + it("Blog 템플릿 섹션을 추가하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", () => { + const store = createEditorStore(); + + store.getState().addBlogTemplateSection(); + + 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("Team 템플릿 섹션을 추가하면 3명의 팀 카드(이름/역할/소개)가 생성되어야 한다", () => { + const store = createEditorStore(); + + store.getState().addTeamTemplateSection(); + + 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명의 팀원 카드에 대해 각 컬럼에 최소 3개 텍스트(이름/역할/소개)를 가정한다. + expect(textBlocks.length).toBeGreaterThanOrEqual(9); + + 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("Footer 템플릿 섹션을 추가하면 링크/카피라이트 텍스트가 포함된 섹션이 생성되어야 한다", () => { + const store = createEditorStore(); + + store.getState().addFooterTemplateSection(); + + 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[]; + expect(textBlocks.length).toBeGreaterThanOrEqual(2); + + textBlocks.forEach((tb) => { + expect(tb.sectionId).toBe(section.id); + expect(tb.columnId).toBe(firstColumnId); + }); + + expect(selectedBlockId).toBe(blocks[blocks.length - 1].id); + }); + it("Features 템플릿 섹션을 추가하면 3컬럼 섹션과 각 컬럼의 제목/설명 텍스트가 생성되어야 한다", () => { const store = createEditorStore();