diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cb0b1e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install deps + run: npm ci + + - name: Unit/Component tests (Vitest) + run: npm test + env: + CI: true + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: E2E tests (Playwright) + run: npm run test:e2e + env: + CI: true diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md new file mode 100644 index 0000000..f402efb --- /dev/null +++ b/MAIN_PLAN.md @@ -0,0 +1,173 @@ +# 메인플랜: 랜딩 페이지 빌더 + +## 목표 +- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. +- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. +- 폼: 다양한 필드 타입(파일 업로드 제외), 커스텀 name, action URL 설정. action 미지정 시 Google Sheets 저장. +- 다국어 지원(i18n), 접근성 준수, SEO/OG 메타, 분석 스니펫 삽입. + +## 확정 요구사항 +- 단일 페이지 기준. +- 이미지: 외부 URL 또는 로컬 업로드 허용(서버 저장 없음, ZIP 패키징). CDN 허용(예: Google Fonts 등). +- 폼 필드 타입: 텍스트/이메일/전화/셀렉트/라디오/체크박스/텍스트에어리어/날짜 등(파일 업로드는 추후). +- 검증 규칙 필요. 스팸 방지: 기본(honeypot, 제출지연 타임스탬프). +- 제출 경로: 1) 사용자가 action URL 지정 → 2) 미지정 시 Google Sheets. + - 기본: Google Apps Script 웹앱 템플릿 가이드 제공(사용자 배포 URL 사용)。 + - 대안: 서비스계정 + Google Sheets API(선택). +- 다국어 지원 필요. 접근성 준수. SEO/OG 포함. 분석 스니펫 삽입 기능 필요. +- 스택: React 기반. Next.js(App Router) + TS + Tailwind + shadcn/ui 권장. + +## 아키텍처 개요 +- 빌더 앱: Next.js(App Router, TS), Tailwind, shadcn/ui, dnd-kit, Zustand. +- 도메인 스키마: Page → Section[] → Block[] → Form(fields, validation, actionUrl) → Theme. +- 에셋 매니저: 사용자가 업로드한 이미지를 메모리/브라우저에서 관리, 파일명 해싱 후 `assets/` 경로로 참조 재작성. +- HTML Exporter: JSON 스키마 → 정적 HTML/CSS/JS 생성. 로컬 업로드 이미지는 `assets/`로 참조, 외부 URL은 그대로 유지. +- 제출 경로: action 사용 또는 Google Sheets(기본 Apps Script 템플릿 제공). +- i18n: next-intl. a11y: Testing Library + axe-core 스모크. SEO/OG 메타 생성. +- 내보내기: ZIP(브라우저 JSZip 우선). 필요 시 서버(API Route) 대안 제공. 로컬 업로드 이미지는 ZIP 내 `assets/` 폴더에 파일로 포함. + +## UI/UX 리디자인 계획(합의안) +- **정보 구조(3패널)** + - 좌: 컴포넌트 패널(Hero/FAQ/CTA + Form 프리셋) + - 중: 캔버스(dnd-kit, 섹션/필드 배치·정렬·삭제) + - 우: 인스펙터(선택 요소 속성/검증/스타일 편집) +- **상단 툴바**: 언어, 테마, 프리셋, Export ZIP +- **프리뷰**: 실시간 렌더, 반응형 뷰포트 토글 +- **계약 고정**: 스키마(섹션/폼/검증), 상태 인터페이스, Exporter 입·출력 + +### 단계별 TDD 진행 +1) 레이아웃 스캐폴딩(shadcn/ui) + 접근성 라벨링 테스트 +2) dnd 캔버스 1차: 섹션 드래그 정렬/삭제 테스트 → 최소 구현 +3) 인스펙터 1차: 선택 섹션/필드 기본 속성 편집 테스트 → 연동 +4) 컴포넌트 패널: 카드 추가(섹션/필드 프리셋) 테스트 → 드래그 추가 +5) 프리뷰 강화: 실시간 반영/뷰포트 토글 스모크 테스트 + +### 현재 상태(MVP) +- 섹션 추가/삭제, 폼 필드 구성(옵션/필수/패턴/최대길이), i18n, ZIP Export, 로컬 에셋 포함 동작 확인 +- Next.js 16: `middleware` → `proxy` 마이그레이션 완료 +- dnd 상호작용 테스트 및 구현 안정화(정렬/삭제 버튼 폴백 포함) +- 섹션 프리셋 확장: Features/Testimonials 카드 추가 및 프리뷰/Exporter 스텁 반영 완료 +- SEO/Analytics/OG 인스펙터 추가 및 프리뷰 반영 +- Exporter: og:image 메타 태그 반영 및 테스트 추가 +- a11y: jest-axe 스모크 테스트 통과(FormBuilderPanel), dnd-kit 라이브 리전/안내 존재 검증 +- 라벨-id 매핑 강화(중앙 입력/인스펙터/FAQ/CTA/Features/Testimonials) +- 프리뷰 뷰포트 토글(Desktop/Mobile) + localStorage 영속화 +- Features/Testimonials 인스펙터 편집 UI(리스트 추가/삭제/편집) + 테스트 +- Exporter 타입 보강(any 제거) 및 CSS 보강(Features/Testimonials) +- E2E(Playwright): + - 기본 플로우(섹션 추가→편집→프리뷰→Export 클릭) + - dnd 키보드 폴백(Up/Down) 재정렬 + - SEO/Analytics/OG 프리뷰 반영 검증 + - Export ZIP 메타(SEO/OG/Analytics) 검증(테스트 훅 사용) + - 프리뷰 뷰포트 토글(aria-pressed/키보드 접근) 검증 + +### a11y/반응형 정책(Exporter) +- 문서 내비게이션: skip link, `
` 적용 +- 섹션 식별: `aria-labelledby` + 섹션 헤딩 id(features/testimonials/faq) +- 이미지 대체텍스트: hero img `alt` = heading 파생 +- 포커스 표시: 포커스 가능한 요소 outline 표시 +- 반응형: 모바일 컨테이너 패딩 축소, grid 자동 칼럼, `prefers-reduced-motion` 준수 + +### 최근 보강 내역(접근성/테스트) +- 인스펙터 입력에 `aria-describedby` 연결(SEO/Analytics/OG/Hero/CTA) +- SEO/Analytics/OG/Hero/CTA/FAQ 헬프텍스트 추가 +- Exporter 구조 스냅샷 강화: `
` 내부 섹션/폼 포함 검증 +- Exporter CSS 토큰 스냅샷: skip-link/focus outline/반응형/모션 감소 토큰 존재 검증 +- Features/Testimonials `aria-labelledby`/헤딩 id 일치 검증 추가 +- Exporter 헤딩 레벨 일관성 전수 테스트(hero=h1, others=h2, FAQ=h3) 추가 +- a11y 색/폰트 최소 기준 스모크(본문 컬러/폰트 패밀리/모바일 h1 폰트 크기) 추가 +- CTA 섹션에 `aria-labelledby` + `

` 추가(헤딩 레벨 일관성) +- 전체 테스트 그린(유닛/컴포넌트/E2E) +- Inspector a11y 스모크: SEO/Analytics/OG 입력과 헬프텍스트의 `aria-describedby` 연결 검증 +- Exporter CSS 스모크: 본문 텍스트 컬러/포커스 outline/모바일 h1 폰트 크기/MQ 토큰 존재 검증 +- FAQ 인스펙터 Q/A 입력 `aria-describedby`(ins-faq-help) 연결 보강 +- Inspector 키보드 UX 스모크: Features/Testimonials 항목 추가( Space/Enter ) 검증 추가 +- Inspector 키보드 UX 스모크: Features/Testimonials 항목 삭제(Remove) Enter/Space 검증 추가 +- 인스펙터 Remove 버튼 키보드 처리(onKeyDown/onKeyUp) 반영 +- Exporter 회귀 스냅샷: heading/ARIA/반응형 토큰 통합 검증 추가 +- E2E 스모크: 프리뷰/Export 마커(heading/ARIA/반응형 CSS) 존재 검증 추가 +- SEO description 기본값 설정으로 Export 스키마 안정화 +- 프리뷰 토글 버튼에 `aria-controls="preview-container"` 추가로 제어 영역 명시 +- 프리뷰 토글에 sr-only 설명 추가 및 두 토글 버튼 `aria-describedby` 연결 +- SectionCanvas 제어 버튼 `aria-label` 스모크(Select/Up/Down/Remove) 추가 +- CSS focus-visible 토큰 스모크(3px solid + HEX 컬러) 추가 +- 인스펙터 Add 버튼 title 보조 힌트 추가(Features/Testimonials/FAQ) 및 일관성 테스트 +- SectionCanvas 버튼 title/aria-label 일관성 스모크 +- 전역 버튼 네이밍 일관성 보강: Inspector Add/Remove 및 SectionCanvas Select/Up/Down/Remove의 title/aria-label 일치 확인 및 스모크 완료 +- a11y 커버리지 최종 점검: 전 섹션 인스펙터 입력 aria-describedby 연결 확인(체크리스트 충족) + +### 다음 작업(계획) +- Exporter 헤딩 레벨 일관성 테스트: hero는 h1, 나머지 섹션은 h2, FAQ 항목은 h3 검증 +- a11y 색/폰트 최소 기준 스모크: 대비/폰트크기 관련 CSS 토큰/규칙 존재 여부 스냅샷 + +### Inspector a11y 커버리지 체크리스트 +- [x] Hero: `Hero Heading` 입력 `aria-describedby` → `ins-hero-heading-help` +- [x] CTA: `CTA Text`/`CTA Href` 입력 `aria-describedby` → `ins-cta-text-help`/`ins-cta-href-help` +- [x] FAQ: `FAQ Q n`/`FAQ A n` 입력 `aria-describedby` → `ins-faq-help` +- [x] Features: `Feature n` 입력 `aria-describedby` → `ins-features-help` +- [x] Testimonials: `Author n`/`Quote n` 입력 `aria-describedby` → `ins-testimonials-help` +- [x] Preview 토글: sr-only 설명(`preview-toggle-help`)과 버튼 `aria-describedby` 연결, `aria-controls="preview-container"` + +### CI/테스트 실행 +- 유닛/컴포넌트: `npm test` (Vitest) +- E2E: `npm run test:e2e` (Playwright) +- 로컬 개발 서버는 Playwright 설정에서 자동 기동(webServer: `npm run dev`) +- GitHub Actions 워크플로: `.github/workflows/ci.yml` + - Node 20 + `npm ci` + - Vitest 실행 → Playwright 브라우저 설치(`npx playwright install --with-deps`) → E2E 실행 + +### CI 최종 점검 +- main/master 브랜치 푸시/PR 시 워크플로 자동 실행 확인 +- 새로 추가된 테스트(헤딩 레벨 전수, 색/폰트 스모크) CI 통합 확인 + +### 다음 스프린트 후보 과제 +- Exporter 회귀 스냅샷 보강(폼 토큰/검증 토큰 범위 확대) +- 인스펙터 신규 섹션 추가 시 a11y 베이스라인 재사용 가이드 마련 +- 버튼 네이밍 일관성 전수 스캔 스모크 범위 확대 + - node 20 + npm ci + - Vitest 실행 → Playwright 브라우저 설치 → E2E 실행 + +## 마일스톤 +1. 스캐폴딩 및 테스트 환경 구성 +2. 스키마 정의(Zod) 및 HTML Exporter TDD +3. 폼 엔진 TDD(필드/검증/스팸 방지) +4. 에셋 매니저 TDD(업로드/메타/해싱/참조 재작성) +5. Google Sheets 연동(Apps Script 템플릿) TDD +6. 빌더 UI(DnD/미리보기/i18n) TDD +7. ZIP 내보내기(JSZip) 및 로컬 에셋 포함 TDD +8. a11y/SEO/OG/분석 스니펫 TDD + E2E + +## 다음 단계(E2E 초안) +- 시나리오: 섹션 추가(Hero/FAQ/Features/Testimonials/CTA) → 인스펙터 편집 → Export ZIP 생성 → HTML 메타/섹션/폼 렌더 확인 +- 환경: Playwright +- 체크리스트: + - 섹션 추가/정렬/삭제 동작 + - 인스펙터 편집 반영(미리보기/Export HTML) + - i18n 라벨 안정성(data-testid 사용) + - a11y 스모크 무결성 유지 + +## TDD 전략 +- 각 기능 단위로: 실패 테스트 작성 → 최소 구현 → 리팩터 → 커버리지 확인. +- 단위: Vitest + Testing Library + jsdom. E2E: Playwright. +- 접근성: axe-core 스모크 테스트. + +## 작업 보드(요약) +- 스키마/도메인(Zod) +- HTML Exporter +- 에셋 매니저 +- 폼 엔진 +- Sheets 연동 +- 빌더 UI +- 내보내기 ZIP +- i18n +- a11y/SEO/OG/Analytics + +## 오류/버그 기록 +- [2025-11-13 22:55] BuilderClientPage 렌더 중 ref 접근 경고 / useRef.current를 렌더 중 참조하여 ESLint 경고 발생 / FormBuilderPanel 통합 시 `useRef(createFormStore())` 사용 / `useMemo(() => createFormStore(), [])`로 스토어 인스턴스 고정하고 `useSyncExternalStore`에 직접 주입하여 해결 / 렌더 중 ref 접근 금지 원칙 재확인 +- [2025-11-14 09:35] Vitest 로그에서 i18n 누락 메시지 경고 확인(en: `builder.action.addSelect/addRadio`, `builder.form.actionUrl/honeypot/minSeconds`) → `messages/en.json`에 키 존재 여부 점검 및 누락 보완 후 재실행, 모든 테스트 그린 유지 +- [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린 + +## 의사결정 기록(ADR) +- [YYYY-MM-DD] Next.js(App Router) 채택: 생태계/배포/서버리스/SEO 편의성. +- [YYYY-MM-DD] Sheets 기본 방안: Apps Script 웹앱 템플릿(사용자 편의/트래픽 분산). +- [YYYY-MM-DD] 로컬 이미지 업로드 허용: 서버 저장 없이 ZIP에 `assets/`로 패키징(브라우저 JSZip 우선). diff --git a/app/[locale]/builder/page.tsx b/app/[locale]/builder/page.tsx new file mode 100644 index 0000000..dbf5eba --- /dev/null +++ b/app/[locale]/builder/page.tsx @@ -0,0 +1,5 @@ +import BuilderClientPage from '@/components/BuilderClientPage' + +export default function BuilderPage() { + return +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..ee1311d --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { getMessages } from '@/lib/i18n/getMessages' +import type { AppLocale } from '@/lib/i18n/locales' +import I18nProvider from '@/components/I18nProvider' +import '../globals.css' + +export const dynamic = 'force-static' + +export default async function LocaleLayout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ locale: AppLocale }> +}) { + const { locale } = await params + const messages = await getMessages(locale) + return ( + + + {children} + + + ) +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 0000000..cb8dd71 --- /dev/null +++ b/app/[locale]/page.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link' +import { defaultLocale } from '@/lib/i18n/locales' + +export default function IndexPage({ params }: { params: { locale: string } }) { + const loc = params.locale || defaultLocale + return ( +
+

Landing Builder

+

Start building your landing page.

+ + Go to Builder + +
+ ) +} diff --git a/app/builder/page.tsx b/app/builder/page.tsx new file mode 100644 index 0000000..dddcf39 --- /dev/null +++ b/app/builder/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function BuilderPage() { + redirect('/en/builder') +} diff --git a/components/BuilderClientPage.addButtons.titleConsistency.test.tsx b/components/BuilderClientPage.addButtons.titleConsistency.test.tsx new file mode 100644 index 0000000..f2bd6e7 --- /dev/null +++ b/components/BuilderClientPage.addButtons.titleConsistency.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('Inspector Add buttons title consistency', () => { + test('Features/Testimonials/FAQ Add buttons have title matching visible label', async () => { + render( + + + + ) + + // Features + fireEvent.click(screen.getByTestId('card-features')) + fireEvent.click(await screen.findByLabelText('Select 0')) + const addFeature = await screen.findByRole('button', { name: /Add Feature Item/i }) + expect(addFeature).toBeInTheDocument() + expect(addFeature.getAttribute('title')).toBe('Add Feature Item') + + // Testimonials + fireEvent.click(screen.getByTestId('card-testimonials')) + fireEvent.click(await screen.findByLabelText('Select 1')) + const addTestimonial = await screen.findByRole('button', { name: /Add Testimonial/i }) + expect(addTestimonial).toBeInTheDocument() + expect(addTestimonial.getAttribute('title')).toBe('Add Testimonial') + + // FAQ + fireEvent.click(screen.getByTestId('card-faq')) + fireEvent.click(await screen.findByLabelText('Select 2')) + const addFaq = await screen.findByRole('button', { name: /Add FAQ Item/i }) + expect(addFaq).toBeInTheDocument() + expect(addFaq.getAttribute('title')).toBe('Add FAQ Item') + }) +}) diff --git a/components/BuilderClientPage.ariaDescribedby.features.test.tsx b/components/BuilderClientPage.ariaDescribedby.features.test.tsx new file mode 100644 index 0000000..92a9c5d --- /dev/null +++ b/components/BuilderClientPage.ariaDescribedby.features.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - inspector aria-describedby (Features/Testimonials)', () => { + test('Features inputs reference help via aria-describedby', async () => { + render( + + + + ) + + fireEvent.click(screen.getByTestId('card-features')) + fireEvent.click(await screen.findByLabelText('Select 0')) + + const feature0 = await screen.findByLabelText('Feature 0') + expect(feature0).toHaveAttribute('aria-describedby', 'ins-features-help') + }) + + test('Testimonials inputs reference help via aria-describedby', async () => { + render( + + + + ) + + fireEvent.click(screen.getByTestId('card-testimonials')) + fireEvent.click(await screen.findByLabelText('Select 0')) + + fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i })) + + const author0 = await screen.findByLabelText('Author 0') + const quote0 = await screen.findByLabelText('Quote 0') + expect(author0).toHaveAttribute('aria-describedby', 'ins-testimonials-help') + expect(quote0).toHaveAttribute('aria-describedby', 'ins-testimonials-help') + }) +}) diff --git a/components/BuilderClientPage.ariaDescribedby.hcf.test.tsx b/components/BuilderClientPage.ariaDescribedby.hcf.test.tsx new file mode 100644 index 0000000..b82fe0c --- /dev/null +++ b/components/BuilderClientPage.ariaDescribedby.hcf.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - inspector aria-describedby (Hero/CTA/FAQ)', () => { + test('Hero input references help via aria-describedby', async () => { + render( + + + + ) + + fireEvent.click(screen.getByTestId('card-hero')) + fireEvent.click(await screen.findByLabelText('Select 0')) + + const hero = await screen.findByLabelText('Hero Heading') + expect(hero).toHaveAttribute('aria-describedby', 'ins-hero-heading-help') + }) + + test('CTA inputs reference help via aria-describedby', async () => { + render( + + + + ) + + fireEvent.click(screen.getByTestId('card-cta')) + fireEvent.click(await screen.findByLabelText('Select 0')) + + const text = await screen.findByLabelText('CTA Text') + const href = await screen.findByLabelText('CTA Href') + expect(text).toHaveAttribute('aria-describedby', 'ins-cta-text-help') + expect(href).toHaveAttribute('aria-describedby', 'ins-cta-href-help') + }) + + test('FAQ inputs reference help via aria-describedby', async () => { + render( + + + + ) + + fireEvent.click(screen.getByTestId('card-faq')) + fireEvent.click(await screen.findByLabelText('Select 0')) + fireEvent.click(await screen.findByRole('button', { name: 'Add FAQ Item' })) + + const q0 = await screen.findByLabelText('FAQ Q 0') + const a0 = await screen.findByLabelText('FAQ A 0') + expect(q0).toHaveAttribute('aria-describedby', 'ins-faq-help') + expect(a0).toHaveAttribute('aria-describedby', 'ins-faq-help') + }) +}) diff --git a/components/BuilderClientPage.ariaDescribedby.test.tsx b/components/BuilderClientPage.ariaDescribedby.test.tsx new file mode 100644 index 0000000..70e5b0a --- /dev/null +++ b/components/BuilderClientPage.ariaDescribedby.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - inspector aria-describedby smoke', () => { + test('SEO/Analytics inputs reference helptext via aria-describedby', async () => { + render( + + + + ) + + const seoTitle = await screen.findByLabelText('SEO Title') + const seoDesc = await screen.findByLabelText('SEO Description') + const ogImage = await screen.findByLabelText('OG Image URL') + const ga4 = await screen.findByLabelText('GA4 Measurement ID') + const pixel = await screen.findByLabelText('Meta Pixel ID') + + expect(seoTitle).toHaveAttribute('aria-describedby', 'ins-seo-title-help') + expect(seoDesc).toHaveAttribute('aria-describedby', 'ins-seo-desc-help') + expect(ogImage).toHaveAttribute('aria-describedby', 'ins-og-image-help') + expect(ga4).toHaveAttribute('aria-describedby', 'ins-ga4-help') + expect(pixel).toHaveAttribute('aria-describedby', 'ins-meta-pixel-help') + }) +}) diff --git a/components/BuilderClientPage.featuresPreview.test.tsx b/components/BuilderClientPage.featuresPreview.test.tsx new file mode 100644 index 0000000..89b42ad --- /dev/null +++ b/components/BuilderClientPage.featuresPreview.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +import BuilderClientPage from '@/components/BuilderClientPage' + +const messages: Record = enMessages as Record + +describe('BuilderClientPage - features/testimonials preview', () => { + it('사이드바 카드 클릭으로 features/testimonials 섹션이 추가되고 프리뷰에 표기된다', () => { + render( + + + + ) + + // features 추가 + fireEvent.click(screen.getByTestId('card-features')) + // testimonials 추가 + fireEvent.click(screen.getByTestId('card-testimonials')) + + // 프리뷰에서 섹션 요약 표기 확인 + expect(screen.getByText(/features — items: 3/)).toBeInTheDocument() + expect(screen.getByText(/testimonials — items: 2/)).toBeInTheDocument() + }) +}) diff --git a/components/BuilderClientPage.formPreview.test.tsx b/components/BuilderClientPage.formPreview.test.tsx new file mode 100644 index 0000000..2616cdc --- /dev/null +++ b/components/BuilderClientPage.formPreview.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import BuilderClientPage from '@/components/BuilderClientPage' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +const messages: Record = enMessages as Record + +describe('BuilderClientPage - form preview', () => { + it('폼 필드 목록과 필수 여부가 미리보기에 표시된다', () => { + render( + + + + ) + + // FormBuilderPanel의 텍스트 필드 추가 버튼 클릭 (data-testid 사용) + fireEvent.click(screen.getByTestId('btn-add-text')) + + // 프리뷰 존재 확인(컴팩트 인스펙터 영역으로 이동됨) + expect(screen.getByText(/Locale:/)).toBeInTheDocument() + + // 첫 필드 항목 표기: 라벨/네임/required 여부(초기 false) + expect(screen.getByText(/Text 1 \(field1\)/)).toBeInTheDocument() + expect(screen.queryByText(/required/)).not.toBeNull() + + // required 토글 후 프리뷰 갱신 확인 + fireEvent.click(screen.getByLabelText('Required 0')) + expect(screen.getAllByText('required').length).toBeGreaterThan(0) + }) +}) diff --git a/components/BuilderClientPage.inspector.cta.test.tsx b/components/BuilderClientPage.inspector.cta.test.tsx new file mode 100644 index 0000000..6b12872 --- /dev/null +++ b/components/BuilderClientPage.inspector.cta.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +import BuilderClientPage from '@/components/BuilderClientPage' + +const messages: Record = enMessages as Record + +describe('BuilderClientPage - Inspector CTA', () => { + it('CTA 선택 후 인스펙터에서 텍스트/링크를 편집할 수 있다', () => { + render( + + + + ) + + // CTA 섹션 추가 + fireEvent.click(screen.getByTestId('btn-add-cta')) + + // 선택 + fireEvent.click(screen.getByLabelText('Select 0')) + + // 인풋 존재 및 값 변경 + const textInput = screen.getByLabelText('CTA Text') as HTMLInputElement + const hrefInput = screen.getByLabelText('CTA Href') as HTMLInputElement + + expect(textInput.value).toBeTruthy() + expect(hrefInput.value).toBeTruthy() + + fireEvent.change(textInput, { target: { value: 'Buy Now' } }) + fireEvent.change(hrefInput, { target: { value: 'https://example.com' } }) + + expect((screen.getByLabelText('CTA Text') as HTMLInputElement).value).toBe('Buy Now') + expect((screen.getByLabelText('CTA Href') as HTMLInputElement).value).toBe('https://example.com') + }) +}) diff --git a/components/BuilderClientPage.inspector.faq.test.tsx b/components/BuilderClientPage.inspector.faq.test.tsx new file mode 100644 index 0000000..91a4542 --- /dev/null +++ b/components/BuilderClientPage.inspector.faq.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +import BuilderClientPage from '@/components/BuilderClientPage' + +const messages: Record = enMessages as Record + +describe('BuilderClientPage - Inspector FAQ', () => { + it('FAQ 선택 후 인스펙터에서 아이템을 편집할 수 있다(쉼표 구분)', () => { + render( + + + + ) + + // FAQ 섹션 추가 및 선택 + fireEvent.click(screen.getByTestId('btn-add-faq')) + fireEvent.click(screen.getByLabelText('Select 0')) + + // 리스트 UI: Add FAQ Item 후 Q/A 입력 변경 확인 + fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' })) + const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement + const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement + fireEvent.change(q0, { target: { value: 'Q1?' } }) + fireEvent.change(a0, { target: { value: 'A1' } }) + expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('Q1?') + expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A1') + }) +}) diff --git a/components/BuilderClientPage.inspector.faqList.test.tsx b/components/BuilderClientPage.inspector.faqList.test.tsx new file mode 100644 index 0000000..b1971bb --- /dev/null +++ b/components/BuilderClientPage.inspector.faqList.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +import BuilderClientPage from '@/components/BuilderClientPage' + +const messages: Record = enMessages as Record + +describe('BuilderClientPage - Inspector FAQ List UI', () => { + it('FAQ 항목을 추가/편집/삭제할 수 있다', () => { + render( + + + + ) + + // FAQ 섹션 추가 및 선택 + fireEvent.click(screen.getByTestId('btn-add-faq')) + fireEvent.click(screen.getByLabelText('Select 0')) + + // Add FAQ Item 버튼 클릭 → 첫 항목 생성 + fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' })) + + const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement + const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement + + fireEvent.change(q0, { target: { value: 'What is it?' } }) + fireEvent.change(a0, { target: { value: 'A landing builder' } }) + + expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('What is it?') + expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A landing builder') + + // 두 번째 항목 추가 후 삭제 + fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' })) + const q1 = screen.getByLabelText('FAQ Q 1') as HTMLInputElement + fireEvent.change(q1, { target: { value: 'How to export?' } }) + fireEvent.click(screen.getByLabelText('Remove FAQ 1')) + + // 삭제 후 첫 항목만 남음 + expect(screen.queryByLabelText('FAQ Q 1')).toBeNull() + expect(screen.getByLabelText('FAQ Q 0')).toBeInTheDocument() + }) +}) diff --git a/components/BuilderClientPage.inspector.featuresTestimonials.test.tsx b/components/BuilderClientPage.inspector.featuresTestimonials.test.tsx new file mode 100644 index 0000000..b50e510 --- /dev/null +++ b/components/BuilderClientPage.inspector.featuresTestimonials.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('Inspector - Features & Testimonials editing', () => { + test('Features: add item and edit value', () => { + render( + + + + ) + + // add Features section via sidebar + fireEvent.click(screen.getByTestId('card-features')) + + // select the section in canvas (index 0) + fireEvent.click(screen.getByLabelText('Select 0')) + + // add item + fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i })) + + // edit item 0 + const input = screen.getByLabelText('Feature 0') as HTMLInputElement + fireEvent.change(input, { target: { value: 'New Feature' } }) + expect(input.value).toBe('New Feature') + }) + + test('Testimonials: add item and edit author/quote', () => { + render( + + + + ) + + // add Testimonials section via sidebar + fireEvent.click(screen.getByTestId('card-testimonials')) + + // select the section (index 0) + fireEvent.click(screen.getByLabelText('Select 0')) + + // add item + fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i })) + + const author = screen.getByLabelText('Author 0') as HTMLInputElement + const quote = screen.getByLabelText('Quote 0') as HTMLInputElement + + fireEvent.change(author, { target: { value: 'Alice' } }) + fireEvent.change(quote, { target: { value: 'Awesome!' } }) + + expect(author.value).toBe('Alice') + expect(quote.value).toBe('Awesome!') + }) +}) diff --git a/components/BuilderClientPage.inspector.test.tsx b/components/BuilderClientPage.inspector.test.tsx new file mode 100644 index 0000000..01ac55a --- /dev/null +++ b/components/BuilderClientPage.inspector.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +import BuilderClientPage from '@/components/BuilderClientPage' + +const messages: Record = enMessages as Record + +describe('BuilderClientPage - Inspector panel', () => { + it('섹션 선택 후 인스펙터에서 Hero heading을 편집할 수 있다', () => { + render( + + + + ) + + // Hero 섹션 추가 (data-testid 사용) + fireEvent.click(screen.getByTestId('btn-add-hero')) + + // 캔버스에서 첫 섹션 선택 + fireEvent.click(screen.getByLabelText('Select 0')) + + // 인스펙터에서 Hero Heading 입력 존재 + const heading = screen.getByLabelText('Hero Heading') as HTMLInputElement + expect(heading).toBeInTheDocument() + // 기본값 존재(Welcome) + expect(heading.value).toBeTruthy() + + // 값 변경 + fireEvent.change(heading, { target: { value: 'Hello World' } }) + expect((screen.getByLabelText('Hero Heading') as HTMLInputElement).value).toBe('Hello World') + }) +}) diff --git a/components/BuilderClientPage.inspectorHelp.test.tsx b/components/BuilderClientPage.inspectorHelp.test.tsx new file mode 100644 index 0000000..28c1a02 --- /dev/null +++ b/components/BuilderClientPage.inspectorHelp.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, within } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - Inspector helptexts (Hero/CTA/FAQ)', () => { + test('Hero, CTA, FAQ helptexts render', async () => { + render( + + + + ) + + // add hero and select it from canvas + screen.getByTestId('card-hero').click() + const sec0 = await screen.findByTestId('sec-item-0') + within(sec0).getByRole('button', { name: /Select/ }).click() + expect(await screen.findByText(/Main heading displayed prominently/i)).toBeInTheDocument() + + // add cta and select it from canvas + screen.getByTestId('card-cta').click() + const sec1 = await screen.findByTestId('sec-item-1') + within(sec1).getByRole('button', { name: /Select/ }).click() + expect(await screen.findByLabelText('CTA Text')).toBeInTheDocument() + expect(await screen.findByLabelText('CTA Href')).toBeInTheDocument() + + // add faq and select it from canvas + screen.getByTestId('card-faq').click() + const sec2 = await screen.findByTestId('sec-item-2') + within(sec2).getByRole('button', { name: /Select/ }).click() + expect(await screen.findByText(/Add at least one FAQ item with Q and A/i)).toBeInTheDocument() + }) +}) diff --git a/components/BuilderClientPage.kbd.featuresTestimonials.test.tsx b/components/BuilderClientPage.kbd.featuresTestimonials.test.tsx new file mode 100644 index 0000000..9dabbdc --- /dev/null +++ b/components/BuilderClientPage.kbd.featuresTestimonials.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('Inspector keyboard UX - Features & Testimonials add item', () => { + test('Features: Space/Enter activates Add Feature Item', () => { + render( + + + + ) + + // Add features section and select it + fireEvent.click(screen.getByTestId('card-features')) + fireEvent.click(screen.getByLabelText('Select 0')) + + const addBtn = screen.getByRole('button', { name: /Add Feature Item/i }) + + // Space key + addBtn.focus() + fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 }) + // Expect first input to appear (Feature 0) + expect(screen.getByLabelText('Feature 0')).toBeInTheDocument() + + // Enter key adds another + addBtn.focus() + fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 }) + expect(screen.getByLabelText('Feature 1')).toBeInTheDocument() + }) + + test('Testimonials: Space/Enter activates Add Testimonial', () => { + render( + + + + ) + + // Add testimonials section and select it + fireEvent.click(screen.getByTestId('card-testimonials')) + fireEvent.click(screen.getByLabelText('Select 0')) + + const addBtn = screen.getByRole('button', { name: /Add Testimonial/i }) + + // Space + addBtn.focus() + fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 }) + expect(screen.getByLabelText('Author 0')).toBeInTheDocument() + expect(screen.getByLabelText('Quote 0')).toBeInTheDocument() + + // Enter adds another + addBtn.focus() + fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 }) + expect(screen.getByLabelText('Author 1')).toBeInTheDocument() + expect(screen.getByLabelText('Quote 1')).toBeInTheDocument() + }) +}) diff --git a/components/BuilderClientPage.kbd.remove.test.tsx b/components/BuilderClientPage.kbd.remove.test.tsx new file mode 100644 index 0000000..58dab64 --- /dev/null +++ b/components/BuilderClientPage.kbd.remove.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('Inspector keyboard UX - Remove actions', () => { + test('Features: Remove with Space/Enter', async () => { + render( + + + + ) + + // Add features and select + fireEvent.click(screen.getByTestId('card-features')) + fireEvent.click(screen.getByLabelText('Select 0')) + + // Add two items + const addBtn = screen.getByRole('button', { name: /Add Feature Item/i }) + let inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ }) + const beforeAdd = inputs.length + fireEvent.click(addBtn) + fireEvent.click(addBtn) + inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ }) + expect(inputs.length).toBe(beforeAdd + 2) + + // Remove last item via Space + inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ }) + const beforeRemoveA = inputs.length + let removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ }) + const rmLastA = removeButtons[removeButtons.length - 1] + rmLastA.focus() + fireEvent.keyUp(rmLastA, { key: ' ', code: 'Space', charCode: 32 }) + await waitFor(() => { + const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ }) + expect(now.length).toBe(beforeRemoveA - 1) + }) + + // Add another then remove last via Enter + fireEvent.click(addBtn) + inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ }) + const beforeRemoveB = inputs.length + removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ }) + const rmLastB = removeButtons[removeButtons.length - 1] + rmLastB.focus() + fireEvent.keyUp(rmLastB, { key: 'Enter', code: 'Enter', charCode: 13 }) + await waitFor(() => { + const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ }) + expect(now.length).toBe(beforeRemoveB - 1) + }) + }) + + test('Testimonials: Remove with Space/Enter', async () => { + render( + + + + ) + + // Add testimonials and select + fireEvent.click(screen.getByTestId('card-testimonials')) + fireEvent.click(screen.getByLabelText('Select 0')) + + // Add two items + const addBtn = screen.getByRole('button', { name: /Add Testimonial/i }) + let authors = screen.queryAllByRole('textbox', { name: /Author \d+/ }) + let quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ }) + const beforeAddT = authors.length + fireEvent.click(addBtn) + fireEvent.click(addBtn) + authors = screen.queryAllByRole('textbox', { name: /Author \d+/ }) + quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ }) + expect(authors.length).toBe(beforeAddT + 2) + expect(quotes.length).toBe(beforeAddT + 2) + + // Remove last item via Enter + authors = screen.queryAllByRole('textbox', { name: /Author \d+/ }) + let beforeRemoveT = authors.length + let rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ }) + let rmLastT = rmTButtons[rmTButtons.length - 1] + rmLastT.focus() + fireEvent.keyUp(rmLastT, { key: 'Enter', code: 'Enter', charCode: 13 }) + await waitFor(() => { + const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ }) + expect(aNow.length).toBe(beforeRemoveT - 1) + }) + + // Add another then remove last via Space + fireEvent.click(addBtn) + authors = screen.queryAllByRole('textbox', { name: /Author \d+/ }) + beforeRemoveT = authors.length + rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ }) + rmLastT = rmTButtons[rmTButtons.length - 1] + rmLastT.focus() + fireEvent.keyUp(rmLastT, { key: ' ', code: 'Space', charCode: 32 }) + await waitFor(() => { + const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ }) + expect(aNow.length).toBe(beforeRemoveT - 1) + }) + }) +}) diff --git a/components/BuilderClientPage.previewViewport.persist.test.tsx b/components/BuilderClientPage.previewViewport.persist.test.tsx new file mode 100644 index 0000000..93c145b --- /dev/null +++ b/components/BuilderClientPage.previewViewport.persist.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test, beforeEach } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - preview viewport persistence & placeholders', () => { + beforeEach(() => { + localStorage.clear() + cleanup() + }) + + test('persists viewport selection in localStorage', () => { + render( + + + + ) + + const container = screen.getByTestId('preview-container') + expect(container).toHaveClass('w-[768px]') + + fireEvent.click(screen.getByRole('button', { name: /Mobile/i })) + expect(container).toHaveClass('w-[375px]') + + // remount + cleanup() + render( + + + + ) + + const container2 = screen.getByTestId('preview-container') + expect(container2).toHaveClass('w-[375px]') + }) + + test('inspector inputs have helpful placeholders', () => { + render( + + + + ) + + expect(screen.getByLabelText('SEO Title')).toHaveAttribute('placeholder') + expect(screen.getByLabelText('SEO Description')).toHaveAttribute('placeholder') + expect(screen.getByLabelText('OG Image URL')).toHaveAttribute('placeholder') + expect(screen.getByLabelText('GA4 Measurement ID')).toHaveAttribute('placeholder') + expect(screen.getByLabelText('Meta Pixel ID')).toHaveAttribute('placeholder') + }) +}) diff --git a/components/BuilderClientPage.previewViewport.test.tsx b/components/BuilderClientPage.previewViewport.test.tsx new file mode 100644 index 0000000..b39789a --- /dev/null +++ b/components/BuilderClientPage.previewViewport.test.tsx @@ -0,0 +1,33 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - preview viewport toggle', () => { + test('toggles preview width between mobile and desktop', () => { + render( + + + + ) + + const container = screen.getByTestId('preview-container') + // default is desktop + expect(container).toHaveClass('w-[768px]') + + fireEvent.click(screen.getByRole('button', { name: /Mobile/i })) + expect(container).toHaveClass('w-[375px]') + + fireEvent.click(screen.getByRole('button', { name: /Desktop/i })) + expect(container).toHaveClass('w-[768px]') + }) +}) diff --git a/components/BuilderClientPage.sectionCanvas.test.tsx b/components/BuilderClientPage.sectionCanvas.test.tsx new file mode 100644 index 0000000..de6684d --- /dev/null +++ b/components/BuilderClientPage.sectionCanvas.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +import BuilderClientPage from '@/components/BuilderClientPage' + +const messages: Record = enMessages as Record + +describe('BuilderClientPage - SectionCanvas integration', () => { + it('섹션을 추가하고 이동/삭제가 동작한다', () => { + render( + + + + ) + + // 섹션 3개 추가: Hero, FAQ, CTA (data-testid 사용) + fireEvent.click(screen.getByTestId('btn-add-hero')) + fireEvent.click(screen.getByTestId('btn-add-faq')) + fireEvent.click(screen.getByTestId('btn-add-cta')) + + const canvas = screen.getByTestId('section-canvas') + expect(canvas).toBeInTheDocument() + + // 초기 순서 확인: hero, faq, cta + let items = canvas.querySelectorAll('li') + expect(items.length).toBe(3) + expect(items[0].textContent).toMatch(/hero/) + expect(items[1].textContent).toMatch(/faq/) + expect(items[2].textContent).toMatch(/cta/) + + // 두 번째 항목(faq)을 위로 이동 (Up 1) + fireEvent.click(screen.getByLabelText('Up 1')) + items = canvas.querySelectorAll('li') + expect(items[0].textContent).toMatch(/faq/) + expect(items[1].textContent).toMatch(/hero/) + + // 마지막 항목(cta)을 위로 이동 (Up 2) + fireEvent.click(screen.getByLabelText('Up 2')) + items = canvas.querySelectorAll('li') + expect(items[0].textContent).toMatch(/faq/) + expect(items[1].textContent).toMatch(/cta/) + expect(items[2].textContent).toMatch(/hero/) + + // 가운데 항목(cta) 삭제 (Remove 1) + fireEvent.click(screen.getByLabelText('Remove 1')) + items = canvas.querySelectorAll('li') + expect(items.length).toBe(2) + expect(items[0].textContent).toMatch(/faq/) + expect(items[1].textContent).toMatch(/hero/) + }) +}) diff --git a/components/BuilderClientPage.seoAnalytics.test.tsx b/components/BuilderClientPage.seoAnalytics.test.tsx new file mode 100644 index 0000000..92d8f77 --- /dev/null +++ b/components/BuilderClientPage.seoAnalytics.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('SEO/Analytics inspector', () => { + test('edits SEO title/description and shows in preview', async () => { + render( + + + + ) + + const seoTitle = screen.getByLabelText('SEO Title') + const seoDesc = screen.getByLabelText('SEO Description') + + fireEvent.change(seoTitle, { target: { value: 'Landing SEO' } }) + fireEvent.change(seoDesc, { target: { value: 'Great page' } }) + + expect(await screen.findByTestId('preview-seo-title')).toHaveTextContent('Landing SEO') + expect(await screen.findByTestId('preview-seo-description')).toHaveTextContent('Great page') + }) + + test('edits Analytics fields and shows in preview', async () => { + render( + + + + ) + + const ga4 = screen.getByLabelText('GA4 Measurement ID') + const mp = screen.getByLabelText('Meta Pixel ID') + + fireEvent.change(ga4, { target: { value: 'G-TEST123' } }) + fireEvent.change(mp, { target: { value: '1234567890' } }) + + expect(await screen.findByTestId('preview-ga4')).toHaveTextContent('G-TEST123') + expect(await screen.findByTestId('preview-meta-pixel')).toHaveTextContent('1234567890') + }) +}) diff --git a/components/BuilderClientPage.seoHelp.test.tsx b/components/BuilderClientPage.seoHelp.test.tsx new file mode 100644 index 0000000..73338d6 --- /dev/null +++ b/components/BuilderClientPage.seoHelp.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('BuilderClientPage - SEO/Analytics helptexts', () => { + test('renders helpful hints under SEO/Analytics/OG inputs', () => { + render( + + + + ) + + expect(screen.getByText(/Shown in browser title and social previews/i)).toBeInTheDocument() + expect(screen.getByText(/Used by search engines and OG description/i)).toBeInTheDocument() + expect(screen.getByText(/Absolute URL recommended/i)).toBeInTheDocument() + expect(screen.getByText(/Format: G-XXXXXXXXXX/i)).toBeInTheDocument() + expect(screen.getByText(/Numeric Page ID/i)).toBeInTheDocument() + }) +}) diff --git a/components/BuilderClientPage.tsx b/components/BuilderClientPage.tsx new file mode 100644 index 0000000..9729e2f --- /dev/null +++ b/components/BuilderClientPage.tsx @@ -0,0 +1,698 @@ +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useTranslations } from 'next-intl' +import { pageSchema, type Page } from '@/lib/schema/page' +import { exportPage } from '@/lib/exporter/html' +import { AssetManager } from '@/lib/assets/assetManager' +import { buildZip } from '@/lib/exporter/zip' +import type { Section } from '@/lib/state/store' +import { createFormStore, type FormState } from '@/lib/state/formStore' +import FormBuilderPanel from '@/components/FormBuilderPanel' +import BuilderShell from '@/components/BuilderShell' +import SectionCanvas from '@/components/SectionCanvas' +import SidebarComponents from '@/components/SidebarComponents' + +function download(filename: string, data: Uint8Array) { + const ab = data.slice().buffer as ArrayBuffer + const blob = new Blob([ab], { type: 'application/zip' }) + try { + if (typeof window !== 'undefined') { + const w = window as unknown as { __captureExport?: boolean; __lastBlob?: Blob } + if (w.__captureExport) { + w.__lastBlob = blob + } + } + } catch {} + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +export default function BuilderClientPage() { + const t = useTranslations() + const [locale, setLocale] = useState<'en' | 'ko'>('en') + const [title, setTitle] = useState('My Landing') + const [primaryColor, setPrimaryColor] = useState('#0ea5e9') + const [fontFamily, setFontFamily] = useState('Inter') + const [sections, setSections] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(null) + const [seoTitle, setSeoTitle] = useState('') + const [seoDescription, setSeoDescription] = useState('') + const [ogImage, setOgImage] = useState('') + const [ga4MeasurementId, setGa4MeasurementId] = useState('') + const [metaPixelId, setMetaPixelId] = useState('') + const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => { + if (typeof window !== 'undefined') { + try { + const saved = localStorage.getItem('builder.viewport') as 'desktop' | 'mobile' | null + if (saved === 'desktop' || saved === 'mobile') return saved + } catch {} + } + return 'desktop' + }) + const fileInputRef = useRef(null) + const assetManagerRef = useRef( + new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'] }) + ) + + useEffect(() => { + try { + localStorage.setItem('builder.viewport', viewport) + } catch {} + }, [viewport]) + const [am, setAm] = useState(null) + useEffect(() => { + setAm(assetManagerRef.current) + }, []) + const [selectedFileName, setSelectedFileName] = useState('') + + // form store (stable instance without ref access in render) + const formStore = useMemo(() => createFormStore(), []) + const formState: FormState = useSyncExternalStore( + formStore.subscribe, + formStore.getState, + formStore.getState + ) + + const addHero = useCallback(async () => { + const fileEl = fileInputRef.current + if (fileEl && fileEl.files && fileEl.files.length > 0) { + const file = fileEl.files[0] + const buf = new Uint8Array(await file.arrayBuffer()) + if (am) { + await am.add({ name: file.name, type: file.type, bytes: buf }) + } + setSelectedFileName(file.name) + } + // 파일이 없어도 Hero 섹션은 추가(이미지는 placeholder 사용) + const hero: Section = { type: 'hero', props: { heading: 'Welcome', subheading: '' } } + setSections((prev) => [...prev, hero]) + }, [am]) + + const addFaq = useCallback(() => { + const faq: Section = { type: 'faq', props: { items: [] } } + setSections((prev) => [...prev, faq]) + }, []) + + const addCta = useCallback(() => { + const cta: Section = { type: 'cta', props: { text: 'Get Started', href: '#' } } + setSections((prev) => [...prev, cta]) + }, []) + + const addFeatures = useCallback(() => { + const features: Section = { type: 'features', props: { items: ['Feature A', 'Feature B', 'Feature C'] } } + setSections((prev) => [...prev, features]) + }, []) + + const addTestimonials = useCallback(() => { + const testimonials: Section = { + type: 'testimonials', + props: { items: [{ author: 'Jane', quote: 'Great product!' }, { author: 'John', quote: 'Loved it.' }] }, + } + setSections((prev) => [...prev, testimonials]) + }, []) + + const removeAt = useCallback((idx: number) => { + setSections((prev) => prev.filter((_, i) => i !== idx)) + }, []) + + const moveSection = useCallback((from: number, to: number) => { + setSections((prev) => { + if (from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev + const next = prev.slice() + const [sp] = next.splice(from, 1) + next.splice(to, 0, sp) + return next + }) + }, []) + + const pageData = useMemo(() => { + const firstName = selectedFileName + + const mappedSections = sections.map((s: Section) => { + if (s.type === 'hero') { + return { + type: 'hero' as const, + props: { + heading: s.props?.heading || 'Welcome', + subheading: s.props?.subheading || '', + imageUrl: firstName ? `local:${firstName}` : 'https://via.placeholder.com/800x400', + }, + } + } + return s + }) + + return ({ + title, + locale, + theme: { primaryColor, fontFamily }, + sections: (mappedSections as unknown) as Page['sections'], + form: formState, + analytics: { ga4MeasurementId, metaPixelId, customHeadHtml: '' }, + seo: { title: seoTitle || title, description: (seoDescription && seoDescription.trim()) ? seoDescription : 'Landing page', ogImage: (ogImage && ogImage.trim()) ? ogImage : undefined }, + } as unknown) as Page + }, [title, locale, primaryColor, fontFamily, sections, formState, selectedFileName, seoTitle, seoDescription, ga4MeasurementId, metaPixelId, ogImage]) + + // test-only: expose preview exporter for E2E to read exported HTML/CSS/JS directly + useEffect(() => { + try { + if (typeof window !== 'undefined') { + const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } } + w.__exportPreview = () => { + try { + return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined }) + } catch { + const fixed: Page = { + ...pageData, + form: { + actionUrl: pageData.form?.actionUrl ?? '', + fields: pageData.form?.fields ?? [], + spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, + }, + } + return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined }) + } + } + } + } catch {} + }, [pageData, am]) + + const doExport = useCallback(async () => { + const valid = pageSchema.parse(pageData) + const exported = exportPage(valid, { assetManager: am ?? undefined }) + try { + if (typeof window !== 'undefined') { + const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } } + if (w.__captureExport) { + w.__lastExport = exported + } + } + } catch {} + const assets = am ? am.toZipStructure() : {} + const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets }) + download('landing.zip', zipBytes) + }, [pageData, am]) + + return ( +
+

{t('builder.title')}

+ + + + +
+ } + center={ +
+
+ + setTitle(e.target.value)} /> +
+
+
+ + +
+
+ + setPrimaryColor(e.target.value)} /> +
+
+ + setFontFamily(e.target.value)} /> +
+
+ +
+ + +
+ +
+ + + +
+ +
+ + setSelectedIndex(i)} + /> +
+
+ } + right={ +
+ +
+

SEO / Analytics

+ + + + + +
+
+

Inspector

+ {selectedIndex !== null && sections[selectedIndex] ? ( + (() => { + const s = sections[selectedIndex] + if (s.type === 'hero') { + const heading = s.props?.heading ?? 'Welcome' + return ( +
+ +

Main heading displayed prominently

+
+ ) + } + if (s.type === 'cta') { + const text = s.props?.text ?? 'Get Started' + const href = s.props?.href ?? '#' + return ( +
+ +

Button label and link for primary action

+ +

Button label and link for primary action

+
+ ) + } + if (s.type === 'faq') { + const items: Array<{ q: string; a: string }> = Array.isArray(s.props?.items) ? (s.props.items as Array<{ q: string; a: string }>) : [] + const addItem = () => { + const nextItems = [...items, { q: '', a: '' }] + setSections((prev) => prev.map((it, idx) => { + if (idx !== selectedIndex) return it + return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section + })) + } + const updateItem = (i: number, patch: { q?: string; a?: string }) => { + const nextItems = items.map((it: { q: string; a: string }, idx: number) => (idx === i ? { ...it, ...patch } : it)) + setSections((prev) => prev.map((it, idx) => { + if (idx !== selectedIndex) return it + return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section + })) + } + const removeItem = (i: number) => { + const nextItems = items.filter((_: { q: string; a: string }, idx: number) => idx !== i) + setSections((prev) => prev.map((it, idx) => { + if (idx !== selectedIndex) return it + return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section + })) + } + return ( +
+ +

Add at least one FAQ item with Q and A

+
    + {items.map((it: { q: string; a: string }, i: number) => ( +
  • +
    + + +
    + +
  • + ))} +
+
+ ) + } + if (s.type === 'features') { + const items: string[] = Array.isArray(s.props?.items) ? (s.props.items as string[]) : [] + const addItem = () => { + const nextItems = [...items, ''] + setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it))) + } + const updateItem = (i: number, v: string) => { + const nextItems = items.map((it, idx) => (idx === i ? v : it)) + setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it))) + } + const removeItem = (i: number) => { + const nextItems = items.filter((_, idx) => idx !== i) + setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it))) + } + return ( +
+ +

Describe a single feature per line

+
    + {items.map((val, i) => ( +
  • + + +
  • + ))} +
+
+ ) + } + if (s.type === 'testimonials') { + const items: Array<{ author: string; quote: string }> = Array.isArray(s.props?.items) + ? (s.props.items as Array<{ author: string; quote: string }>) + : [] + const addItem = () => { + const nextItems = [...items, { author: '', quote: '' }] + setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it))) + } + const updateItem = (i: number, patch: Partial<{ author: string; quote: string }>) => { + const nextItems = items.map((it, idx) => (idx === i ? { ...it, ...patch } : it)) + setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it))) + } + const removeItem = (i: number) => { + const nextItems = items.filter((_, idx) => idx !== i) + setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it))) + } + return ( +
+ +

Add author and quote for each testimonial

+
    + {items.map((it, i) => ( +
  • + + +
    + +
    +
  • + ))} +
+
+ ) + } + return
No editable fields
+ })() + ) : ( +
Nothing selected
+ )} +
+
+

{t('builder.preview')}

+

Toggle preview viewport for the builder preview area

+
+ + +
+
+
+
Title: {pageData.title}
+
Locale: {pageData.locale}
+
SEO Title: {pageData.seo.title}
+
SEO Description: {pageData.seo.description}
+
OG Image: {pageData.seo.ogImage || ''}
+
GA4: {pageData.analytics?.ga4MeasurementId || ''}
+
Meta Pixel: {pageData.analytics?.metaPixelId || ''}
+
+ {sections.map((s: Section, i: number) => { + switch (s.type) { + case 'hero': + return ( +
• hero — heading: {s.props?.heading || 'Welcome'}
+ ) + case 'faq': { + const count = s.props.items?.length || 0 + return ( +
• faq — items: {count}
+ ) + } + case 'cta': { + const text = s.props.text || 'CTA' + const href = s.props.href || '#' + return ( +
• cta — {text} → {href}
+ ) + } + case 'features': { + const count = s.props.items?.length || 0 + return ( +
• features — items: {count}
+ ) + } + case 'testimonials': { + const count = s.props.items?.length || 0 + return ( +
• testimonials — items: {count}
+ ) + } + default: + return null + } + })} +
+
    + {formState.fields.map((f, idx) => ( +
  • + {f.label} ({f.name}) {f.required ? 'required' : ''} +
  • + ))} +
+
+
+
+
+ } + /> + + ) +} diff --git a/components/BuilderShell.test.tsx b/components/BuilderShell.test.tsx new file mode 100644 index 0000000..e3caf51 --- /dev/null +++ b/components/BuilderShell.test.tsx @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import React from 'react' +import BuilderShell from '@/components/BuilderShell' + +describe('BuilderShell', () => { + it('좌/중/우 3패널과 접근성 라벨을 렌더링한다', () => { + render( + LEFT} center={
CENTER
} right={
RIGHT
} /> + ) + + expect(screen.getByTestId('builder-shell')).toBeInTheDocument() + expect(screen.getByLabelText('Components Panel')).toBeInTheDocument() + expect(screen.getByLabelText('Canvas')).toBeInTheDocument() + expect(screen.getByLabelText('Inspector')).toBeInTheDocument() + }) +}) diff --git a/components/BuilderShell.tsx b/components/BuilderShell.tsx new file mode 100644 index 0000000..5e1d91b --- /dev/null +++ b/components/BuilderShell.tsx @@ -0,0 +1,27 @@ +"use client" + +import React from 'react' + +export default function BuilderShell({ + left, + center, + right, +}: { + left: React.ReactNode + center: React.ReactNode + right: React.ReactNode +}) { + return ( +
+ +
+ {center} +
+ +
+ ) +} diff --git a/components/FormBuilderPanel.a11y.test.tsx b/components/FormBuilderPanel.a11y.test.tsx new file mode 100644 index 0000000..b4d5939 --- /dev/null +++ b/components/FormBuilderPanel.a11y.test.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { expect, test } from 'vitest' +import { axe, toHaveNoViolations } from 'jest-axe' +import FormBuilderPanel from './FormBuilderPanel' +import { createFormStore } from '@/lib/state/formStore' + +expect.extend(toHaveNoViolations) +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +test('FormBuilderPanel has no obvious a11y violations', async () => { + const store = createFormStore() + const { container } = render( + + + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() +}) diff --git a/components/FormBuilderPanel.test.tsx b/components/FormBuilderPanel.test.tsx new file mode 100644 index 0000000..f166ee5 --- /dev/null +++ b/components/FormBuilderPanel.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import FormBuilderPanel from '@/components/FormBuilderPanel' +import { createFormStore } from '@/lib/state/formStore' +import type { SelectField, RadioField } from '@/lib/state/formStore' +import { NextIntlClientProvider } from 'next-intl' +import enMessages from '@/messages/en.json' +const messages: Record = enMessages as Record + +describe('FormBuilderPanel', () => { + it('필드 추가와 action URL/스팸 옵션 변경을 수행한다', () => { + const store = createFormStore() + render( + + + + ) + + // 초기 상태 + expect(screen.queryAllByTestId('field-item').length).toBe(0) + + // 필드 추가 2개 (번역 키 이슈 회피: 첫 번째 버튼 클릭) + fireEvent.click(screen.getAllByRole('button')[0]) + fireEvent.click(screen.getAllByRole('button')[0]) + expect(screen.queryAllByTestId('field-item').length).toBe(2) + + // action URL 입력 + const actionInput = screen.getByLabelText('Action URL') as HTMLInputElement + fireEvent.change(actionInput, { target: { value: 'https://example.com/form' } }) + expect(actionInput.value).toBe('https://example.com/form') + expect(store.getState().actionUrl).toBe('https://example.com/form') + + // 스팸 옵션 - honeypot 변경 + const hpInput = screen.getByLabelText('Honeypot Field') as HTMLInputElement + fireEvent.change(hpInput, { target: { value: '_trap' } }) + expect(store.getState().spam.honeypotFieldName).toBe('_trap') + + // 스팸 옵션 - min seconds 변경 + const tsInput = screen.getByLabelText('Min Submit Seconds') as HTMLInputElement + fireEvent.change(tsInput, { target: { value: '5' } }) + expect(store.getState().spam.minSubmitSeconds).toBe(5) + }) + + it('pattern과 maxLength 편집이 상태에 반영된다', () => { + const store = createFormStore() + render( + + + + ) + + // 텍스트 필드 1개 추가 + fireEvent.click(screen.getAllByRole('button')[0]) + + const pat0 = screen.getByLabelText('Pattern 0') as HTMLInputElement + fireEvent.change(pat0, { target: { value: '^[A-Za-z ]+$' } }) + expect(store.getState().fields[0].pattern).toBe('^[A-Za-z ]+$') + + const max0 = screen.getByLabelText('MaxLength 0') as HTMLInputElement + fireEvent.change(max0, { target: { value: '50' } }) + expect(store.getState().fields[0].maxLength).toBe(50) + }) + + it('select/radio 옵션 편집이 동작하고 추가 버튼으로 타입별 필드를 만든다', () => { + const store = createFormStore() + render( + + + + ) + + // select 추가 후 옵션 편집 (버튼 인덱스로 접근) + const buttons1 = screen.getAllByRole('button') + // 버튼 순서: Text, Email, Tel, Date, Textarea, Checkbox, Select, Radio + fireEvent.click(buttons1[6]) + const opts0 = screen.getByLabelText('Options 0') as HTMLInputElement + fireEvent.change(opts0, { target: { value: 'A,B,C' } }) + expect(store.getState().fields[0].type).toBe('select') + expect((store.getState().fields[0] as SelectField).options).toEqual(['A','B','C']) + + // radio 추가 후 옵션 편집 + const buttons2 = screen.getAllByRole('button') + fireEvent.click(buttons2[7]) + const opts1 = screen.getByLabelText('Options 1') as HTMLInputElement + fireEvent.change(opts1, { target: { value: 'S,M,L' } }) + expect(store.getState().fields[1].type).toBe('radio') + expect((store.getState().fields[1] as RadioField).options).toEqual(['S','M','L']) + + // email/checkbox/textarea/date/tel도 추가 버튼 존재 (인덱스 기반 클릭) + const buttons3 = screen.getAllByRole('button') + fireEvent.click(buttons3[1]) // Email + fireEvent.click(buttons3[5]) // Checkbox + fireEvent.click(buttons3[4]) // Textarea + fireEvent.click(buttons3[3]) // Date + fireEvent.click(buttons3[2]) // Tel + + const names = store.getState().fields.map((f) => f.type) + expect(names).toContain('email') + expect(names).toContain('checkbox') + expect(names).toContain('textarea') + expect(names).toContain('date') + expect(names).toContain('tel') + }) + + it('필드 레이블/필수 편집, 이동/삭제가 동작한다', () => { + const store = createFormStore() + render( + + + + ) + + // 3개 추가 + fireEvent.click(screen.getAllByRole('button')[0]) + fireEvent.click(screen.getAllByRole('button')[0]) + fireEvent.click(screen.getAllByRole('button')[0]) + expect(screen.queryAllByTestId('field-item').length).toBe(3) + + // 첫 필드 레이블 변경 + const label0 = screen.getByLabelText('Label 0') as HTMLInputElement + fireEvent.change(label0, { target: { value: 'Name' } }) + expect(store.getState().fields[0].label).toBe('Name') + + // 첫 필드 required 토글 + const req0 = screen.getByLabelText('Required 0') as HTMLInputElement + fireEvent.click(req0) + expect(store.getState().fields[0].required).toBe(true) + + // 첫 필드를 아래로 이동 (0 -> 1) + fireEvent.click(screen.getByRole('button', { name: 'Down 0' })) + expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field1','field3']) + + // 두 번째(이전 첫) 필드 삭제 + fireEvent.click(screen.getByRole('button', { name: 'Remove 1' })) + expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field3']) + }) +}) diff --git a/components/FormBuilderPanel.tsx b/components/FormBuilderPanel.tsx new file mode 100644 index 0000000..6a78c50 --- /dev/null +++ b/components/FormBuilderPanel.tsx @@ -0,0 +1,160 @@ +"use client" + +import React, { useCallback, useSyncExternalStore } from 'react' +import type { FormState, FormField } from '@/lib/state/formStore' +import { useTranslations } from 'next-intl' + +type Store = { + getState: () => FormState + subscribe: (listener: () => void) => () => void +} + +export default function FormBuilderPanel({ store }: { store: Store }) { + const t = useTranslations() + const state = useSyncExternalStore(store.subscribe, store.getState, store.getState) + + const onAddText = useCallback(() => { + const idx = state.fields.length + 1 + const name = `field${idx}` + store.getState().addField({ type: 'text', name, label: `Text ${idx}`, required: false }) + }, [state.fields.length, store]) + + const nextName = () => `field${state.fields.length + 1}` + const onAddEmail = () => store.getState().addField({ type: 'email', name: nextName(), label: 'Email', required: false }) + const onAddTel = () => store.getState().addField({ type: 'tel', name: nextName(), label: 'Tel', required: false }) + const onAddDate = () => store.getState().addField({ type: 'date', name: nextName(), label: 'Date', required: false }) + const onAddTextarea = () => store.getState().addField({ type: 'textarea', name: nextName(), label: 'Message', required: false }) + const onAddCheckbox = () => store.getState().addField({ type: 'checkbox', name: nextName(), label: 'Agree', required: false }) + const onAddSelect = () => store.getState().addField({ type: 'select', name: nextName(), label: 'Select', required: false, options: [] }) + const onAddRadio = () => store.getState().addField({ type: 'radio', name: nextName(), label: 'Radio', required: false, options: [] }) + + const onChangeAction = (e: React.ChangeEvent) => { + store.getState().setActionUrl(e.target.value) + } + + const onChangeHp = (e: React.ChangeEvent) => { + store.getState().setSpam({ honeypotFieldName: e.target.value }) + } + + const onChangeTs = (e: React.ChangeEvent) => { + const v = parseInt(e.target.value || '0', 10) + store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v }) + } + + const onChangeLabel = (i: number, e: React.ChangeEvent) => { + store.getState().updateField(i, { label: e.target.value }) + } + + const onToggleRequired = (i: number) => { + const cur = store.getState().fields[i]?.required ?? false + store.getState().updateField(i, { required: !cur }) + } + + const onDown = (i: number) => { + if (i < state.fields.length - 1) { + store.getState().moveField(i, i + 1) + } + } + + const onRemove = (i: number) => { + store.getState().removeField(i) + } + + return ( +
+
+ + + + + + + + +
+ +
+ + + +
+ +
    + {state.fields.map((f: FormField, i: number) => ( +
  • +
    +
    {f.type} - {f.name}
    + + + {(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && ( +
    + + +
    + )} + {(f.type === 'select' || f.type === 'radio') && ( + + )} +
    +
    + + +
    +
  • + ))} +
+
+ ) +} diff --git a/components/I18nProvider.tsx b/components/I18nProvider.tsx new file mode 100644 index 0000000..773e3c9 --- /dev/null +++ b/components/I18nProvider.tsx @@ -0,0 +1,20 @@ +"use client" + +import { NextIntlClientProvider } from 'next-intl' +import React from 'react' + +export default function I18nProvider({ + locale, + messages, + children, +}: { + locale: string + messages: Record + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/components/SectionCanvas.a11y.test.tsx b/components/SectionCanvas.a11y.test.tsx new file mode 100644 index 0000000..d7fa077 --- /dev/null +++ b/components/SectionCanvas.a11y.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react' +import { describe, test, expect } from 'vitest' +import SectionCanvas from './SectionCanvas' +import type { Section } from '@/lib/state/store' + +describe('SectionCanvas a11y', () => { + test('renders dnd-kit live region and instructions', () => { + const sections: Section[] = [] + render( + {}} + onRemove={() => {}} + onSelect={() => {}} + /> + ) + + // dnd-kit screen reader instructions should be present in the DOM + expect(screen.getByText(/To pick up a draggable item/i)).toBeInTheDocument() + + // Live region for announcements should exist + const liveRegion = screen.getByRole('status') + expect(liveRegion).toHaveAttribute('aria-live', 'assertive') + }) +}) diff --git a/components/SectionCanvas.ariaLabels.test.tsx b/components/SectionCanvas.ariaLabels.test.tsx new file mode 100644 index 0000000..021c2f0 --- /dev/null +++ b/components/SectionCanvas.ariaLabels.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import React, { useState } from 'react' +import SectionCanvas from './SectionCanvas' +import type { Section } from '@/lib/state/store' + +function Wrapper({ withSelect = true }: { withSelect?: boolean }) { + const [sections, setSections] = useState([ + { type: 'hero', props: { heading: 'H', subheading: '' } }, + { type: 'faq', props: { items: [] } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ]) + const onMove = (from: number, to: number) => { + setSections((prev) => { + const next = prev.slice() + const [sp] = next.splice(from, 1) + next.splice(to, 0, sp) + return next + }) + } + const onRemove = () => {} + const onSelect = withSelect ? () => {} : undefined + return +} + +describe('SectionCanvas aria-labels on control buttons', () => { + test('Select/Up/Down/Remove buttons expose index-based aria-labels', () => { + render() + + const list = screen.getByTestId('section-canvas') + const items = within(list).getAllByTestId(/sec-item-/) + + // check first item labels (index 0) + const first = items[0] + const sel0 = within(first).getByLabelText('Select 0') as HTMLButtonElement + const up0 = within(first).getByLabelText('Up 0') as HTMLButtonElement + const down0 = within(first).getByLabelText('Down 0') as HTMLButtonElement + const rm0 = within(first).getByLabelText('Remove 0') as HTMLButtonElement + expect(sel0).toBeInTheDocument() + expect(up0).toBeInTheDocument() + expect(down0).toBeInTheDocument() + expect(rm0).toBeInTheDocument() + expect(sel0.title).toBe(sel0.getAttribute('aria-label')) + expect(up0.title).toBe(up0.getAttribute('aria-label')) + expect(down0.title).toBe(down0.getAttribute('aria-label')) + expect(rm0.title).toBe(rm0.getAttribute('aria-label')) + + // check last item labels (index 2) + const last = items[2] + const sel2 = within(last).getByLabelText('Select 2') as HTMLButtonElement + const up2 = within(last).getByLabelText('Up 2') as HTMLButtonElement + const down2 = within(last).getByLabelText('Down 2') as HTMLButtonElement + const rm2 = within(last).getByLabelText('Remove 2') as HTMLButtonElement + expect(sel2).toBeInTheDocument() + expect(up2).toBeInTheDocument() + expect(down2).toBeInTheDocument() + expect(rm2).toBeInTheDocument() + expect(sel2.title).toBe(sel2.getAttribute('aria-label')) + expect(up2.title).toBe(up2.getAttribute('aria-label')) + expect(down2.title).toBe(down2.getAttribute('aria-label')) + expect(rm2.title).toBe(rm2.getAttribute('aria-label')) + }) + + test('Up/Down buttons still present when onSelect not provided', () => { + render() + const list = screen.getByTestId('section-canvas') + const items = within(list).getAllByTestId(/sec-item-/) + + // when onSelect is undefined, Select button should not exist + expect(within(items[0]).queryByLabelText('Select 0')).toBeNull() + + // Up/Down/Remove must still be present + expect(within(items[0]).getByLabelText('Up 0')).toBeInTheDocument() + expect(within(items[0]).getByLabelText('Down 0')).toBeInTheDocument() + expect(within(items[0]).getByLabelText('Remove 0')).toBeInTheDocument() + }) +}) diff --git a/components/SectionCanvas.dnd.test.tsx b/components/SectionCanvas.dnd.test.tsx new file mode 100644 index 0000000..78f2b28 --- /dev/null +++ b/components/SectionCanvas.dnd.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React, { useState } from 'react' +import SectionCanvas from '@/components/SectionCanvas' +import type { Section } from '@/lib/state/store' + +type DragEvt = { active: { id: string }; over: { id: string } } + +vi.mock('@dnd-kit/core', async () => { + const actual = await vi.importActual('@dnd-kit/core') + return { + ...actual, + DndContext: ({ onDragEnd, children }: { onDragEnd?: (e: DragEvt) => void; children: React.ReactNode }) => ( +
onDragEnd?.({ active: { id: 'sec-0' }, over: { id: 'sec-2' } })}> + {children} +
+ ), + closestCenter: vi.fn(), + PointerSensor: function () {}, + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), + } +}) + +vi.mock('@dnd-kit/sortable', async () => { + const actual = await vi.importActual('@dnd-kit/sortable') + return { + ...actual, + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {} as Record, + listeners: {} as Record, + setNodeRef: () => {}, + transform: null as unknown, + transition: null as unknown, + }), + verticalListSortingStrategy: vi.fn(), + } +}) + +function Wrapper() { + const [sections, setSections] = useState([ + { type: 'hero', props: { heading: 'Welcome', subheading: '' } }, + { type: 'faq', props: { items: [] } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ]) + return ( + { + const next = sections.slice() + const [sp] = next.splice(from, 1) + next.splice(to, 0, sp) + setSections(next) + }} + onRemove={(i) => setSections((prev) => prev.filter((_, idx) => idx !== i))} + /> + ) +} + +describe('SectionCanvas - dnd', () => { + it('드래그 종료 이벤트로 섹션 순서를 변경한다', () => { + render() + const list = screen.getByTestId('section-canvas') + let items = list.querySelectorAll('li') + expect(items[0].textContent).toMatch(/hero/) + expect(items[1].textContent).toMatch(/faq/) + expect(items[2].textContent).toMatch(/cta/) + + fireEvent.click(screen.getByTestId('mock-dnd')) + + items = list.querySelectorAll('li') + expect(items[0].textContent).toMatch(/faq/) + expect(items[1].textContent).toMatch(/cta/) + expect(items[2].textContent).toMatch(/hero/) + }) +}) diff --git a/components/SectionCanvas.keyboard.test.tsx b/components/SectionCanvas.keyboard.test.tsx new file mode 100644 index 0000000..fc7299e --- /dev/null +++ b/components/SectionCanvas.keyboard.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import React, { useState } from 'react' +import SectionCanvas from './SectionCanvas' +import type { Section } from '@/lib/state/store' + +function Wrapper() { + const [sections, setSections] = useState([ + { type: 'hero', props: { heading: 'H', subheading: '' } }, + { type: 'faq', props: { items: [] } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ]) + + const onMove = (from: number, to: number) => { + setSections((prev) => { + const next = prev.slice() + const [sp] = next.splice(from, 1) + next.splice(to, 0, sp) + return next + }) + } + + return ( + {}} + onSelect={() => {}} + /> + ) +} + +describe('SectionCanvas keyboard/fallback reorder', () => { + test('clicking Down and Up buttons reorders items', () => { + render() + + // initial order: hero, faq, cta + const list = screen.getByTestId('section-canvas') + let items = within(list).getAllByTestId(/sec-item-/) + expect(items[0]).toHaveTextContent('hero') + expect(items[1]).toHaveTextContent('faq') + expect(items[2]).toHaveTextContent('cta') + + // move first item (index 0) down + fireEvent.click(within(items[0]).getByLabelText('Down 0')) + + items = within(list).getAllByTestId(/sec-item-/) + expect(items[0]).toHaveTextContent('faq') + expect(items[1]).toHaveTextContent('hero') + expect(items[2]).toHaveTextContent('cta') + + // move last item up + fireEvent.click(within(items[2]).getByLabelText('Up 2')) + + items = within(list).getAllByTestId(/sec-item-/) + expect(items[0]).toHaveTextContent('faq') + expect(items[1]).toHaveTextContent('cta') + expect(items[2]).toHaveTextContent('hero') + }) +}) diff --git a/components/SectionCanvas.tsx b/components/SectionCanvas.tsx new file mode 100644 index 0000000..bc50c1c --- /dev/null +++ b/components/SectionCanvas.tsx @@ -0,0 +1,74 @@ +"use client" + +import React, { useMemo } from 'react' +import type { Section } from '@/lib/state/store' +import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core' +import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +function SortableItem({ id, children, testId }: { id: string; children: React.ReactNode; testId?: string }) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + return ( +
  • + {children} +
  • + ) +} + +export default function SectionCanvas({ + sections, + onMove, + onRemove, + onSelect, +}: { + sections: Section[] + onMove: (from: number, to: number) => void + onRemove: (index: number) => void + onSelect?: (index: number) => void +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })) + const ids = useMemo(() => sections.map((_, i) => `sec-${i}`), [sections]) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over) return + if (active.id === over.id) return + const from = ids.indexOf(String(active.id)) + const to = ids.indexOf(String(over.id)) + if (from !== -1 && to !== -1) onMove(from, to) + } + + return ( + + +
      + {sections.map((s, i) => ( + + {s.type} +
      + {onSelect && ( + + )} + + + +
      +
      + ))} +
    +
    +
    + ) +} diff --git a/components/SidebarComponents.tsx b/components/SidebarComponents.tsx new file mode 100644 index 0000000..cd7ae8b --- /dev/null +++ b/components/SidebarComponents.tsx @@ -0,0 +1,45 @@ +"use client" + +import React from 'react' + +export default function SidebarComponents({ + onAddHero, + onAddFAQ, + onAddCTA, + onAddFeatures, + onAddTestimonials, +}: { + onAddHero: () => void + onAddFAQ: () => void + onAddCTA: () => void + onAddFeatures: () => void + onAddTestimonials: () => void +}) { + return ( +
    +

    Components

    +
    + + + + + +
    +
    + ) +} diff --git a/components/ariaTitleLabelConsistency.test.tsx b/components/ariaTitleLabelConsistency.test.tsx new file mode 100644 index 0000000..cc53feb --- /dev/null +++ b/components/ariaTitleLabelConsistency.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import { describe, expect, test } from 'vitest' +import BuilderClientPage from './BuilderClientPage' +import messages from '../messages/en.json' + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe('Buttons title/aria-label consistency', () => { + test('Inspector Remove buttons have title matching aria-label', async () => { + render( + + + + ) + + // Add Features section and select + fireEvent.click(screen.getByTestId('card-features')) + fireEvent.click(await screen.findByLabelText('Select 0')) + // Ensure at least one item + fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i })) + + // Validate Features remove buttons + const featureRemoveButtons = screen.getAllByRole('button', { name: /Remove Feature \d+/ }) + for (const btn of featureRemoveButtons) { + const aria = btn.getAttribute('aria-label') || '' + const title = btn.getAttribute('title') || '' + expect(title).toBe(aria) + } + + // Add Testimonials section and select + fireEvent.click(screen.getByTestId('card-testimonials')) + fireEvent.click(await screen.findByLabelText('Select 1')) + // Ensure at least one item to expose remove button + fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i })) + + // Validate Testimonials remove buttons + const testimonialRemoveButtons = screen.getAllByRole('button', { name: /Remove Testimonial \d+/ }) + for (const btn of testimonialRemoveButtons) { + const aria = btn.getAttribute('aria-label') || '' + const title = btn.getAttribute('title') || '' + expect(title).toBe(aria) + } + }) +}) diff --git a/lib/assets/assetManager.test.ts b/lib/assets/assetManager.test.ts new file mode 100644 index 0000000..93d2ecf --- /dev/null +++ b/lib/assets/assetManager.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { AssetManager } from '@/lib/assets/assetManager' + +function buf(bytes: number[]): Uint8Array { + return new Uint8Array(bytes) +} + +describe('AssetManager', () => { + it('업로드 이미지를 해시하여 assets/.로 관리하고 중복을 제거한다', async () => { + const am = new AssetManager({ + maxBytes: 10 * 1024 * 1024, + allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], + }) + + const a1 = await am.add({ name: 'hero.png', type: 'image/png', bytes: buf([1,2,3,4]) }) + const a2 = await am.add({ name: 'another.png', type: 'image/png', bytes: buf([1,2,3,4]) }) + const a3 = await am.add({ name: 'logo.svg', type: 'image/svg+xml', bytes: buf([60,115,118,103,62]) }) + + expect(a1.path).toMatch(/^assets\/[a-f0-9]{8}\.png$/) + expect(a2.path).toBe(a1.path) // 동일 바이트 -> 동일 해시 -> 중복 제거 + expect(a3.path).toMatch(/^assets\/[a-f0-9]{8}\.svg$/) + + const list = am.list() + expect(list.length).toBe(2) + + const zipStruct = am.toZipStructure() + const keys = Object.keys(zipStruct) + expect(keys).toContain(a1.path) + expect(keys).toContain(a3.path) + expect(zipStruct[a1.path]).toBeInstanceOf(Uint8Array) + }) + + it('허용되지 않은 확장자/크기는 거절한다', async () => { + const am = new AssetManager({ maxBytes: 5, allowedExts: ['png'] }) + await expect( + am.add({ name: 'bad.gif', type: 'image/gif', bytes: buf([1]) }) + ).rejects.toThrow(/extension/i) + + await expect( + am.add({ name: 'big.png', type: 'image/png', bytes: buf([1,2,3,4,5,6]) }) + ).rejects.toThrow(/size/i) + }) + + it('rewriteUrl: http/https는 그대로, local:은 assets 경로로 치환한다', async () => { + const am = new AssetManager({ maxBytes: 1024, allowedExts: ['png'] }) + const external = am.rewriteUrl('https://cdn.example.com/x.png') + expect(external).toBe('https://cdn.example.com/x.png') + + const ref = await am.add({ name: 'pic.png', type: 'image/png', bytes: buf([9,9,9]) }) + const rewritten = am.rewriteUrl(`local:${ref.originalName}`) + expect(rewritten).toBe(ref.path) + }) +}) diff --git a/lib/assets/assetManager.ts b/lib/assets/assetManager.ts new file mode 100644 index 0000000..5784bfa --- /dev/null +++ b/lib/assets/assetManager.ts @@ -0,0 +1,98 @@ +export type AssetInput = { + name: string + type: string + bytes: Uint8Array +} + +export type AssetRef = { + originalName: string + mimeType: string + bytes: Uint8Array + hash8: string + ext: string + path: string // assets/. +} + +export type AssetManagerOptions = { + maxBytes: number + allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg'] +} + +function extOf(name: string): string { + const i = name.lastIndexOf('.') + return i >= 0 ? name.slice(i + 1).toLowerCase() : '' +} + +// Simple FNV-1a 32-bit hash -> 8-hex chars +function hash8(bytes: Uint8Array): string { + let h = 0x811c9dc5 >>> 0 + for (let i = 0; i < bytes.length; i++) { + h ^= bytes[i] + h = Math.imul(h, 0x01000193) >>> 0 + } + return ('00000000' + h.toString(16)).slice(-8) +} + +export class AssetManager { + private readonly maxBytes: number + private readonly allowed: Set + private byHash = new Map() + private byOriginal = new Map() // originalName -> path + + constructor(opts: AssetManagerOptions) { + this.maxBytes = opts.maxBytes + this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase())) + } + + async add(file: AssetInput): Promise { + const ext = extOf(file.name) + if (!this.allowed.has(ext)) { + throw new Error('Invalid extension') + } + if (file.bytes.length > this.maxBytes) { + throw new Error('File size exceeded') + } + const h = hash8(file.bytes) + const existing = this.byHash.get(h) + if (existing) { + // map original name for rewrite convenience + if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, existing.path) + return existing + } + + const path = `assets/${h}.${ext}` + const ref: AssetRef = { + originalName: file.name, + mimeType: file.type, + bytes: file.bytes, + hash8: h, + ext, + path, + } + this.byHash.set(h, ref) + if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, path) + return ref + } + + list(): AssetRef[] { + return Array.from(this.byHash.values()) + } + + toZipStructure(): Record { + const out: Record = {} + for (const ref of this.byHash.values()) { + out[ref.path] = ref.bytes + } + return out + } + + rewriteUrl(urlOrLocalRef: string): string { + if (/^https?:\/\//i.test(urlOrLocalRef)) return urlOrLocalRef + if (urlOrLocalRef.startsWith('local:')) { + const key = urlOrLocalRef.slice('local:'.length) + const p = this.byOriginal.get(key) + return p ?? urlOrLocalRef + } + return urlOrLocalRef + } +} diff --git a/lib/exporter/a11yMainContain.test.ts b/lib/exporter/a11yMainContain.test.ts new file mode 100644 index 0000000..bd9033a --- /dev/null +++ b/lib/exporter/a11yMainContain.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest' +import { exportPage } from './html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(): Page { + return pageSchema.parse({ + title: 'T', + locale: 'en', + theme: { primaryColor: '#000000', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://example.com/hero.png' } }, + { type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 'SEO', description: 'Desc' }, + }) +} + +describe('Exporter a11y/structure - main contains sections and form', () => { + test('main landmark wraps sections and form markup', () => { + const out = exportPage(makePage()) + expect(out.html).toContain('
    ') + // sections inside main + expect(out.html).toMatch(//) + // form inside main + expect(out.html).toMatch(//) + }) +}) diff --git a/lib/exporter/a11yResponsive.test.ts b/lib/exporter/a11yResponsive.test.ts new file mode 100644 index 0000000..081b859 --- /dev/null +++ b/lib/exporter/a11yResponsive.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest' +import { exportPage } from './html' +import { pageSchema, type Page } from '@/lib/schema/page' + +function makePage(overrides: Partial = {}): Page { + const base: Page = { + title: 'T', + locale: 'en', + theme: { primaryColor: '#000000', fontFamily: 'Inter' }, + sections: [ + { type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/img.png' } }, + { type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } }, + { type: 'features', props: { items: ['One', 'Two'] } }, + { type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } }, + { type: 'cta', props: { text: 'Go', href: '#' } }, + ] as Page['sections'], + form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 'SEO', description: 'Desc' }, + } + return pageSchema.parse({ ...base, ...overrides }) +} + +describe('Exporter a11y/responsive enhancements', () => { + test('includes skip link and main landmark', () => { + const out = exportPage(makePage()) + expect(out.html).toContain('