From efa8d34fe27d0409a1e26a0dbbcf1f753f40e2a8 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Tue, 18 Nov 2025 17:14:02 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5:=20FAQ/Pricing/Testimonials=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/editor/page.tsx | 24 +++ src/features/editor/state/editorStore.ts | 230 +++++++++++++++++++++++ tests/e2e/editor.spec.ts | 41 ++++ tests/unit/editorStore.spec.ts | 86 +++++++++ 4 files changed, 381 insertions(+) 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();