test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
This commit is contained in:
@@ -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
|
||||||
+173
@@ -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, `<main id="main" role="main">` 적용
|
||||||
|
- 섹션 식별: `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 구조 스냅샷 강화: `<main>` 내부 섹션/폼 포함 검증
|
||||||
|
- Exporter CSS 토큰 스냅샷: skip-link/focus outline/반응형/모션 감소 토큰 존재 검증
|
||||||
|
- Features/Testimonials `aria-labelledby`/헤딩 id 일치 검증 추가
|
||||||
|
- Exporter 헤딩 레벨 일관성 전수 테스트(hero=h1, others=h2, FAQ=h3) 추가
|
||||||
|
- a11y 색/폰트 최소 기준 스모크(본문 컬러/폰트 패밀리/모바일 h1 폰트 크기) 추가
|
||||||
|
- CTA 섹션에 `aria-labelledby` + `<h2 id="cta-heading">` 추가(헤딩 레벨 일관성)
|
||||||
|
- 전체 테스트 그린(유닛/컴포넌트/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 우선).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
export default function BuilderPage() {
|
||||||
|
return <BuilderClientPage />
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<I18nProvider locale={locale} messages={messages}>{children}</I18nProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-6">
|
||||||
|
<h1 className="text-2xl font-semibold mb-4">Landing Builder</h1>
|
||||||
|
<p className="mb-4">Start building your landing page.</p>
|
||||||
|
<Link className="text-sky-600 underline" href={`/${loc}/builder`}>
|
||||||
|
Go to Builder
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function BuilderPage() {
|
||||||
|
redirect('/en/builder')
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector Add buttons title consistency', () => {
|
||||||
|
test('Features/Testimonials/FAQ Add buttons have title matching visible label', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - inspector aria-describedby (Features/Testimonials)', () => {
|
||||||
|
test('Features inputs reference help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - inspector aria-describedby (Hero/CTA/FAQ)', () => {
|
||||||
|
test('Hero input references help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - inspector aria-describedby smoke', () => {
|
||||||
|
test('SEO/Analytics inputs reference helptext via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - features/testimonials preview', () => {
|
||||||
|
it('사이드바 카드 클릭으로 features/testimonials 섹션이 추가되고 프리뷰에 표기된다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - form preview', () => {
|
||||||
|
it('폼 필드 목록과 필수 여부가 미리보기에 표시된다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector CTA', () => {
|
||||||
|
it('CTA 선택 후 인스펙터에서 텍스트/링크를 편집할 수 있다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector FAQ', () => {
|
||||||
|
it('FAQ 선택 후 인스펙터에서 아이템을 편집할 수 있다(쉼표 구분)', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector FAQ List UI', () => {
|
||||||
|
it('FAQ 항목을 추가/편집/삭제할 수 있다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector - Features & Testimonials editing', () => {
|
||||||
|
test('Features: add item and edit value', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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!')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector panel', () => {
|
||||||
|
it('섹션 선택 후 인스펙터에서 Hero heading을 편집할 수 있다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector helptexts (Hero/CTA/FAQ)', () => {
|
||||||
|
test('Hero, CTA, FAQ helptexts render', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector keyboard UX - Features & Testimonials add item', () => {
|
||||||
|
test('Features: Space/Enter activates Add Feature Item', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector keyboard UX - Remove actions', () => {
|
||||||
|
test('Features: Remove with Space/Enter', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - preview viewport persistence & placeholders', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('persists viewport selection in localStorage', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const container2 = screen.getByTestId('preview-container')
|
||||||
|
expect(container2).toHaveClass('w-[375px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspector inputs have helpful placeholders', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - preview viewport toggle', () => {
|
||||||
|
test('toggles preview width between mobile and desktop', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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]')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - SectionCanvas integration', () => {
|
||||||
|
it('섹션을 추가하고 이동/삭제가 동작한다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 섹션 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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SEO/Analytics inspector', () => {
|
||||||
|
test('edits SEO title/description and shows in preview', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - SEO/Analytics helptexts', () => {
|
||||||
|
test('renders helpful hints under SEO/Analytics/OG inputs', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Section[]>([])
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||||
|
const [seoTitle, setSeoTitle] = useState<string>('')
|
||||||
|
const [seoDescription, setSeoDescription] = useState<string>('')
|
||||||
|
const [ogImage, setOgImage] = useState<string>('')
|
||||||
|
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
|
||||||
|
const [metaPixelId, setMetaPixelId] = useState<string>('')
|
||||||
|
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<HTMLInputElement | null>(null)
|
||||||
|
const assetManagerRef = useRef<AssetManager>(
|
||||||
|
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<AssetManager | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
setAm(assetManagerRef.current)
|
||||||
|
}, [])
|
||||||
|
const [selectedFileName, setSelectedFileName] = useState<string>('')
|
||||||
|
|
||||||
|
// 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<Page>(() => {
|
||||||
|
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 (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">{t('builder.title')}</h1>
|
||||||
|
|
||||||
|
<BuilderShell
|
||||||
|
left={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SidebarComponents
|
||||||
|
onAddHero={addHero}
|
||||||
|
onAddFAQ={addFaq}
|
||||||
|
onAddCTA={addCta}
|
||||||
|
onAddFeatures={addFeatures}
|
||||||
|
onAddTestimonials={addTestimonials}
|
||||||
|
/>
|
||||||
|
<FormBuilderPanel store={formStore} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
center={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-title">{t('builder.field.title')}</label>
|
||||||
|
<input id="builder-title" className="border rounded px-3 py-2 w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-locale">{t('builder.field.locale')}</label>
|
||||||
|
<select id="builder-locale" className="border rounded px-3 py-2 w-full" value={locale} onChange={(e) => setLocale(e.target.value as 'en' | 'ko')}>
|
||||||
|
<option value="en">en</option>
|
||||||
|
<option value="ko">ko</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-primary-color">{t('builder.field.primaryColor')}</label>
|
||||||
|
<input id="builder-primary-color" type="color" className="border rounded px-2 py-2 w-full h-10" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-font-family">{t('builder.field.fontFamily')}</label>
|
||||||
|
<input id="builder-font-family" className="border rounded px-3 py-2 w-full" value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-hero-file">{t('builder.upload.hero')}</label>
|
||||||
|
<input id="builder-hero-file" ref={fileInputRef} type="file" accept="image/*" className="block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button data-testid="btn-add-hero" className="bg-sky-600 text-white rounded px-4 py-2" onClick={addHero}>{t('builder.action.addHero')}</button>
|
||||||
|
<button data-testid="btn-add-faq" className="bg-gray-200 rounded px-4 py-2" onClick={addFaq}>{t('builder.action.addFAQ')}</button>
|
||||||
|
<button data-testid="btn-add-cta" className="bg-gray-200 rounded px-4 py-2" onClick={addCta}>{t('builder.action.addCTA')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium">{t('builder.sections')}</label>
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={moveSection}
|
||||||
|
onRemove={removeAt}
|
||||||
|
onSelect={(i) => setSelectedIndex(i)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button className="bg-emerald-600 text-white rounded px-4 py-2 w-full" onClick={doExport}>{t('builder.action.export')}</button>
|
||||||
|
<div className="border rounded p-3 space-y-2">
|
||||||
|
<h2 className="text-sm font-semibold mb-1">SEO / Analytics</h2>
|
||||||
|
<label className="block" htmlFor="ins-seo-title">
|
||||||
|
<span className="sr-only">SEO Title</span>
|
||||||
|
<input
|
||||||
|
id="ins-seo-title"
|
||||||
|
aria-label="SEO Title"
|
||||||
|
aria-describedby="ins-seo-title-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="e.g. Product landing page"
|
||||||
|
value={seoTitle}
|
||||||
|
onChange={(e) => setSeoTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-seo-title-help" className="mt-1 text-xs text-gray-500">Shown in browser title and social previews</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-seo-desc">
|
||||||
|
<span className="sr-only">SEO Description</span>
|
||||||
|
<input
|
||||||
|
id="ins-seo-desc"
|
||||||
|
aria-label="SEO Description"
|
||||||
|
aria-describedby="ins-seo-desc-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="Short description shown in search/OG"
|
||||||
|
value={seoDescription}
|
||||||
|
onChange={(e) => setSeoDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-seo-desc-help" className="mt-1 text-xs text-gray-500">Used by search engines and OG description</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-og-image">
|
||||||
|
<span className="sr-only">OG Image URL</span>
|
||||||
|
<input
|
||||||
|
id="ins-og-image"
|
||||||
|
aria-label="OG Image URL"
|
||||||
|
aria-describedby="ins-og-image-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="https://.../og-image.png"
|
||||||
|
value={ogImage}
|
||||||
|
onChange={(e) => setOgImage(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-og-image-help" className="mt-1 text-xs text-gray-500">Absolute URL recommended</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-ga4">
|
||||||
|
<span className="sr-only">GA4 Measurement ID</span>
|
||||||
|
<input
|
||||||
|
id="ins-ga4"
|
||||||
|
aria-label="GA4 Measurement ID"
|
||||||
|
aria-describedby="ins-ga4-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="G-XXXXXXXXXX"
|
||||||
|
value={ga4MeasurementId}
|
||||||
|
onChange={(e) => setGa4MeasurementId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-ga4-help" className="mt-1 text-xs text-gray-500">Format: G-XXXXXXXXXX</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-meta-pixel">
|
||||||
|
<span className="sr-only">Meta Pixel ID</span>
|
||||||
|
<input
|
||||||
|
id="ins-meta-pixel"
|
||||||
|
aria-label="Meta Pixel ID"
|
||||||
|
aria-describedby="ins-meta-pixel-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="1234567890"
|
||||||
|
value={metaPixelId}
|
||||||
|
onChange={(e) => setMetaPixelId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-meta-pixel-help" className="mt-1 text-xs text-gray-500">Numeric Page ID</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">Inspector</h2>
|
||||||
|
{selectedIndex !== null && sections[selectedIndex] ? (
|
||||||
|
(() => {
|
||||||
|
const s = sections[selectedIndex]
|
||||||
|
if (s.type === 'hero') {
|
||||||
|
const heading = s.props?.heading ?? 'Welcome'
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block" htmlFor="ins-hero-heading">
|
||||||
|
<span className="sr-only">Hero Heading</span>
|
||||||
|
<input
|
||||||
|
id="ins-hero-heading"
|
||||||
|
aria-label="Hero Heading"
|
||||||
|
aria-describedby="ins-hero-heading-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={heading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), heading: v } } as Section
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="ins-hero-heading-help" className="mt-1 text-xs text-gray-500">Main heading displayed prominently</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.type === 'cta') {
|
||||||
|
const text = s.props?.text ?? 'Get Started'
|
||||||
|
const href = s.props?.href ?? '#'
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block" htmlFor="ins-cta-text">
|
||||||
|
<span className="sr-only">CTA Text</span>
|
||||||
|
<input
|
||||||
|
id="ins-cta-text"
|
||||||
|
aria-label="CTA Text"
|
||||||
|
aria-describedby="ins-cta-text-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), text: v } } as Section
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="ins-cta-text-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
|
||||||
|
<label className="block" htmlFor="ins-cta-href">
|
||||||
|
<span className="sr-only">CTA Href</span>
|
||||||
|
<input
|
||||||
|
id="ins-cta-href"
|
||||||
|
aria-label="CTA Href"
|
||||||
|
aria-describedby="ins-cta-href-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={href}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), href: v } } as Section
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="ins-cta-href-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add FAQ Item" onClick={addItem}>Add FAQ Item</button>
|
||||||
|
<p id="ins-faq-help" className="text-xs text-gray-500">Add at least one FAQ item with Q and A</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((it: { q: string; a: string }, i: number) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||||
|
<label className="block" htmlFor={`ins-faq-q-${i}`}>
|
||||||
|
<span className="sr-only">FAQ Q {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-faq-q-${i}`}
|
||||||
|
aria-label={`FAQ Q ${i}`}
|
||||||
|
aria-describedby="ins-faq-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.q ?? ''}
|
||||||
|
onChange={(e) => updateItem(i, { q: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor={`ins-faq-a-${i}`}>
|
||||||
|
<span className="sr-only">FAQ A {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-faq-a-${i}`}
|
||||||
|
aria-label={`FAQ A ${i}`}
|
||||||
|
aria-describedby="ins-faq-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.a ?? ''}
|
||||||
|
onChange={(e) => updateItem(i, { a: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" aria-label={`Remove FAQ ${i}`} className="px-2 py-1 border rounded text-sm text-red-600" onClick={() => removeItem(i)}>Remove</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Feature Item" onClick={addItem}>Add Feature Item</button>
|
||||||
|
<p id="ins-features-help" className="text-xs text-gray-500">Describe a single feature per line</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((val, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2">
|
||||||
|
<label className="block flex-1" htmlFor={`ins-feature-${i}`}>
|
||||||
|
<span className="sr-only">Feature {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-feature-${i}`}
|
||||||
|
aria-label={`Feature ${i}`}
|
||||||
|
aria-describedby="ins-features-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => updateItem(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove Feature ${i}`}
|
||||||
|
title={`Remove Feature ${i}`}
|
||||||
|
className="px-2 py-1 border rounded text-sm text-red-600"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Testimonial" onClick={addItem}>Add Testimonial</button>
|
||||||
|
<p id="ins-testimonials-help" className="text-xs text-gray-500">Add author and quote for each testimonial</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<li key={i} className="grid grid-cols-2 gap-2 items-start">
|
||||||
|
<label className="block" htmlFor={`ins-author-${i}`}>
|
||||||
|
<span className="sr-only">Author {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-author-${i}`}
|
||||||
|
aria-label={`Author ${i}`}
|
||||||
|
aria-describedby="ins-testimonials-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.author}
|
||||||
|
onChange={(e) => updateItem(i, { author: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor={`ins-quote-${i}`}>
|
||||||
|
<span className="sr-only">Quote {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-quote-${i}`}
|
||||||
|
aria-label={`Quote ${i}`}
|
||||||
|
aria-describedby="ins-testimonials-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.quote}
|
||||||
|
onChange={(e) => updateItem(i, { quote: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove Testimonial ${i}`}
|
||||||
|
title={`Remove Testimonial ${i}`}
|
||||||
|
className="px-2 py-1 border rounded text-sm text-red-600"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className="text-xs text-gray-500">No editable fields</div>
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">Nothing selected</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{t('builder.preview')}</h2>
|
||||||
|
<p id="preview-toggle-help" className="sr-only">Toggle preview viewport for the builder preview area</p>
|
||||||
|
<div className="mb-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Desktop"
|
||||||
|
aria-pressed={viewport === 'desktop'}
|
||||||
|
aria-controls="preview-container"
|
||||||
|
aria-describedby="preview-toggle-help"
|
||||||
|
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
|
||||||
|
viewport === 'desktop'
|
||||||
|
? 'bg-sky-600 text-white border-sky-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewport('desktop')}
|
||||||
|
>
|
||||||
|
Desktop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mobile"
|
||||||
|
aria-pressed={viewport === 'mobile'}
|
||||||
|
aria-controls="preview-container"
|
||||||
|
aria-describedby="preview-toggle-help"
|
||||||
|
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
|
||||||
|
viewport === 'mobile'
|
||||||
|
? 'bg-sky-600 text-white border-sky-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewport('mobile')}
|
||||||
|
>
|
||||||
|
Mobile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<div
|
||||||
|
data-testid="preview-container"
|
||||||
|
className={`border rounded p-3 ${viewport === 'desktop' ? 'w-[768px]' : 'w-[375px]'} mx-auto`}
|
||||||
|
>
|
||||||
|
<div className="mb-2"><strong>Title:</strong> {pageData.title}</div>
|
||||||
|
<div className="mb-2"><strong>Locale:</strong> {pageData.locale}</div>
|
||||||
|
<div className="mb-2"><strong>SEO Title:</strong> <span data-testid="preview-seo-title">{pageData.seo.title}</span></div>
|
||||||
|
<div className="mb-2"><strong>SEO Description:</strong> <span data-testid="preview-seo-description">{pageData.seo.description}</span></div>
|
||||||
|
<div className="mb-2"><strong>OG Image:</strong> <span data-testid="preview-og-image">{pageData.seo.ogImage || ''}</span></div>
|
||||||
|
<div className="mb-2"><strong>GA4:</strong> <span data-testid="preview-ga4">{pageData.analytics?.ga4MeasurementId || ''}</span></div>
|
||||||
|
<div className="mb-2"><strong>Meta Pixel:</strong> <span data-testid="preview-meta-pixel">{pageData.analytics?.metaPixelId || ''}</span></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sections.map((s: Section, i: number) => {
|
||||||
|
switch (s.type) {
|
||||||
|
case 'hero':
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• hero — heading: {s.props?.heading || 'Welcome'}</div>
|
||||||
|
)
|
||||||
|
case 'faq': {
|
||||||
|
const count = s.props.items?.length || 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• faq — items: {count}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'cta': {
|
||||||
|
const text = s.props.text || 'CTA'
|
||||||
|
const href = s.props.href || '#'
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• cta — {text} → {href}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'features': {
|
||||||
|
const count = s.props.items?.length || 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• features — items: {count}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'testimonials': {
|
||||||
|
const count = s.props.items?.length || 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• testimonials — items: {count}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ul className="list-disc pl-5 mt-2">
|
||||||
|
{formState.fields.map((f, idx) => (
|
||||||
|
<li key={`${f.name}-${idx}`} className="text-sm">
|
||||||
|
{f.label} ({f.name}) {f.required ? 'required' : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<BuilderShell left={<div>LEFT</div>} center={<div>CENTER</div>} right={<div>RIGHT</div>} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('builder-shell')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Components Panel')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Canvas')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Inspector')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<div className="h-full w-full grid grid-cols-1 md:grid-cols-[280px_1fr_320px] gap-4 p-4" data-testid="builder-shell">
|
||||||
|
<aside className="border rounded p-3 min-h-[200px]" aria-label="Components Panel" data-testid="panel-left">
|
||||||
|
{left}
|
||||||
|
</aside>
|
||||||
|
<main className="border rounded p-3 min-h-[200px]" aria-label="Canvas" data-testid="panel-center">
|
||||||
|
{center}
|
||||||
|
</main>
|
||||||
|
<aside className="border rounded p-3 min-h-[200px]" aria-label="Inspector" data-testid="panel-right">
|
||||||
|
{right}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('FormBuilderPanel has no obvious a11y violations', async () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
const results = await axe(container)
|
||||||
|
expect(results).toHaveNoViolations()
|
||||||
|
})
|
||||||
@@ -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<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('FormBuilderPanel', () => {
|
||||||
|
it('필드 추가와 action URL/스팸 옵션 변경을 수행한다', () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 초기 상태
|
||||||
|
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(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 텍스트 필드 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(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<HTMLInputElement>) => {
|
||||||
|
store.getState().setActionUrl(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeHp = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
store.getState().setSpam({ honeypotFieldName: e.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeTs = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = parseInt(e.target.value || '0', 10)
|
||||||
|
store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeLabel = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" data-testid="btn-add-text" onClick={onAddText} className="px-2 py-1 border rounded">{t('builder.action.addText')}</button>
|
||||||
|
<button type="button" onClick={onAddEmail} className="px-2 py-1 border rounded">{t('builder.action.addEmail')}</button>
|
||||||
|
<button type="button" onClick={onAddTel} className="px-2 py-1 border rounded">{t('builder.action.addTel')}</button>
|
||||||
|
<button type="button" onClick={onAddDate} className="px-2 py-1 border rounded">{t('builder.action.addDate')}</button>
|
||||||
|
<button type="button" onClick={onAddTextarea} className="px-2 py-1 border rounded">{t('builder.action.addTextarea')}</button>
|
||||||
|
<button type="button" onClick={onAddCheckbox} className="px-2 py-1 border rounded">{t('builder.action.addCheckbox')}</button>
|
||||||
|
<button type="button" onClick={onAddSelect} className="px-2 py-1 border rounded">{t('builder.action.addSelect')}</button>
|
||||||
|
<button type="button" onClick={onAddRadio} className="px-2 py-1 border rounded">{t('builder.action.addRadio')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block" htmlFor="form-action-url">
|
||||||
|
<span className="block text-sm">{t('builder.form.actionUrl')}</span>
|
||||||
|
<input id="form-action-url" aria-label="Action URL" className="border px-2 py-1 w-full" value={state.actionUrl}
|
||||||
|
onChange={onChangeAction} />
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="form-honeypot">
|
||||||
|
<span className="block text-sm">{t('builder.form.honeypot')}</span>
|
||||||
|
<input id="form-honeypot" aria-label="Honeypot Field" className="border px-2 py-1 w-full" value={state.spam.honeypotFieldName}
|
||||||
|
onChange={onChangeHp} />
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="form-min-seconds">
|
||||||
|
<span className="block text-sm">{t('builder.form.minSeconds')}</span>
|
||||||
|
<input id="form-min-seconds" aria-label="Min Submit Seconds" type="number" className="border px-2 py-1 w-full" value={state.spam.minSubmitSeconds}
|
||||||
|
onChange={onChangeTs} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{state.fields.map((f: FormField, i: number) => (
|
||||||
|
<li key={`${f.name}-${i}`} data-testid="field-item" className="text-sm border rounded p-2 flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs text-gray-500">{f.type} - {f.name}</div>
|
||||||
|
<label className="block" htmlFor={`field-label-${i}`}>
|
||||||
|
<span className="sr-only">Label {i}</span>
|
||||||
|
<input id={`field-label-${i}`} aria-label={`Label ${i}`} className="border px-2 py-1 w-full" value={f.label}
|
||||||
|
onChange={(e) => onChangeLabel(i, e)} />
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 mt-1" htmlFor={`field-required-${i}`}>
|
||||||
|
<input id={`field-required-${i}`} aria-label={`Required ${i}`} type="checkbox" checked={!!f.required}
|
||||||
|
onChange={() => onToggleRequired(i)} />
|
||||||
|
<span className="text-xs">required</span>
|
||||||
|
</label>
|
||||||
|
{(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && (
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||||
|
<label className="block" htmlFor={`field-pattern-${i}`}>
|
||||||
|
<span className="sr-only">Pattern {i}</span>
|
||||||
|
<input
|
||||||
|
id={`field-pattern-${i}`}
|
||||||
|
aria-label={`Pattern ${i}`}
|
||||||
|
className="border px-2 py-1 w-full"
|
||||||
|
value={f.pattern ?? ''}
|
||||||
|
onChange={(e) => store.getState().updateField(i, { pattern: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor={`field-maxlen-${i}`}>
|
||||||
|
<span className="sr-only">MaxLength {i}</span>
|
||||||
|
<input
|
||||||
|
id={`field-maxlen-${i}`}
|
||||||
|
aria-label={`MaxLength ${i}`}
|
||||||
|
type="number"
|
||||||
|
className="border px-2 py-1 w-full"
|
||||||
|
value={f.maxLength ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value || '0', 10)
|
||||||
|
store.getState().updateField(i, { maxLength: Number.isNaN(v) ? undefined : v })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(f.type === 'select' || f.type === 'radio') && (
|
||||||
|
<label className="block mt-1" htmlFor={`field-options-${i}`}>
|
||||||
|
<span className="sr-only">Options {i}</span>
|
||||||
|
<input id={`field-options-${i}`} aria-label={`Options ${i}`} className="border px-2 py-1 w-full"
|
||||||
|
value={(f.type === 'select' || f.type === 'radio') ? f.options.join(',') : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const opts = e.target.value.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
if (f.type === 'select' || f.type === 'radio') {
|
||||||
|
store.getState().updateField(i, { options: opts } as Partial<FormField>)
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" aria-label={`Down ${i}`} onClick={() => onDown(i)} className="px-2 py-1 border rounded">Down {i}</button>
|
||||||
|
<button type="button" aria-label={`Remove ${i}`} onClick={() => onRemove(i)} className="px-2 py-1 border rounded text-red-600">Remove {i}</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, string>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={() => {}}
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Section[]>([
|
||||||
|
{ 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 <SectionCanvas sections={sections} onMove={onMove} onRemove={onRemove} onSelect={onSelect} />
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SectionCanvas aria-labels on control buttons', () => {
|
||||||
|
test('Select/Up/Down/Remove buttons expose index-based aria-labels', () => {
|
||||||
|
render(<Wrapper />)
|
||||||
|
|
||||||
|
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(<Wrapper withSelect={false} />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof import('@dnd-kit/core')>('@dnd-kit/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
DndContext: ({ onDragEnd, children }: { onDragEnd?: (e: DragEvt) => void; children: React.ReactNode }) => (
|
||||||
|
<div data-testid="mock-dnd" onClick={() => onDragEnd?.({ active: { id: 'sec-0' }, over: { id: 'sec-2' } })}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
closestCenter: vi.fn(),
|
||||||
|
PointerSensor: function () {},
|
||||||
|
useSensor: vi.fn(() => ({})),
|
||||||
|
useSensors: vi.fn(() => []),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@dnd-kit/sortable', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useSortable: () => ({
|
||||||
|
attributes: {} as Record<string, unknown>,
|
||||||
|
listeners: {} as Record<string, unknown>,
|
||||||
|
setNodeRef: () => {},
|
||||||
|
transform: null as unknown,
|
||||||
|
transition: null as unknown,
|
||||||
|
}),
|
||||||
|
verticalListSortingStrategy: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function Wrapper() {
|
||||||
|
const [sections, setSections] = useState<Section[]>([
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: '' } },
|
||||||
|
{ type: 'faq', props: { items: [] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
])
|
||||||
|
return (
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={(from, to) => {
|
||||||
|
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(<Wrapper />)
|
||||||
|
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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Section[]>([
|
||||||
|
{ 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 (
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={onMove}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SectionCanvas keyboard/fallback reorder', () => {
|
||||||
|
test('clicking Down and Up buttons reorders items', () => {
|
||||||
|
render(<Wrapper />)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<li ref={setNodeRef} style={style} {...attributes} {...listeners} data-testid={testId} className="flex items-center justify-between border rounded px-3 py-2">
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
<ul className="space-y-1" data-testid="section-canvas">
|
||||||
|
{sections.map((s, i) => (
|
||||||
|
<SortableItem id={`sec-${i}`} key={`sec-${i}`} testId={`sec-item-${i}`}>
|
||||||
|
<span className="text-sm">{s.type}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onSelect && (
|
||||||
|
<button aria-label={`Select ${i}`} title={`Select ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => onSelect(i)}>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button aria-label={`Up ${i}`} title={`Up ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i > 0 && onMove(i, i - 1)}>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<button aria-label={`Down ${i}`} title={`Down ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i < sections.length - 1 && onMove(i, i + 1)}>
|
||||||
|
Down
|
||||||
|
</button>
|
||||||
|
<button aria-label={`Remove ${i}`} title={`Remove ${i}`} className="text-sm text-red-600 border rounded px-2 py-1" onClick={() => onRemove(i)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-2" data-testid="sidebar-components">
|
||||||
|
<h2 className="text-sm font-semibold">Components</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<button data-testid="card-hero" className="border rounded p-2 text-left" onClick={onAddHero}>
|
||||||
|
<div className="text-sm font-medium">Hero</div>
|
||||||
|
<div className="text-xs text-gray-500">Large heading + image</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-faq" className="border rounded p-2 text-left" onClick={onAddFAQ}>
|
||||||
|
<div className="text-sm font-medium">FAQ</div>
|
||||||
|
<div className="text-xs text-gray-500">Questions & answers</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-cta" className="border rounded p-2 text-left" onClick={onAddCTA}>
|
||||||
|
<div className="text-sm font-medium">CTA</div>
|
||||||
|
<div className="text-xs text-gray-500">Action button</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-features" className="border rounded p-2 text-left" onClick={onAddFeatures}>
|
||||||
|
<div className="text-sm font-medium">Features</div>
|
||||||
|
<div className="text-xs text-gray-500">List of product features</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-testimonials" className="border rounded p-2 text-left" onClick={onAddTestimonials}>
|
||||||
|
<div className="text-sm font-medium">Testimonials</div>
|
||||||
|
<div className="text-xs text-gray-500">Customer quotes</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Buttons title/aria-label consistency', () => {
|
||||||
|
test('Inspector Remove buttons have title matching aria-label', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/<hash>.<ext>로 관리하고 중복을 제거한다', 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:<name>은 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/<hash8>.<ext>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>
|
||||||
|
private byHash = new Map<string, AssetRef>()
|
||||||
|
private byOriginal = new Map<string, string>() // originalName -> path
|
||||||
|
|
||||||
|
constructor(opts: AssetManagerOptions) {
|
||||||
|
this.maxBytes = opts.maxBytes
|
||||||
|
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(file: AssetInput): Promise<AssetRef> {
|
||||||
|
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<string, Uint8Array> {
|
||||||
|
const out: Record<string, Uint8Array> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('<main id="main" role="main">')
|
||||||
|
// sections inside main
|
||||||
|
expect(out.html).toMatch(/<main[\s\S]*<section class="hero"[\s\S]*<section class="faq"[\s\S]*<section class="cta"[\s\S]*<\/main>/)
|
||||||
|
// form inside main
|
||||||
|
expect(out.html).toMatch(/<main[\s\S]*<form method="post"[\s\S]*<\/main>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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> = {}): 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('<a href="#main" class="skip-link">')
|
||||||
|
expect(out.html).toContain('<main id="main" role="main">')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sections use aria-labelledby with h2 ids', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<section class="faq" aria-labelledby="faq-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="faq-heading">FAQ</h2>')
|
||||||
|
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
|
||||||
|
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hero image alt derives from heading', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
|
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('responsive and a11y CSS tokens exist', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
expect(out.css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(out.css).toMatch(/outline: 3px solid/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
describe('Analytics snippet injection', () => {
|
||||||
|
const base = {
|
||||||
|
title: 'Analytics Test',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'Analytics Test', description: 'desc' },
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GA4 measurement ID가 있으면 GA4 스니펫을 삽입한다', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
...base,
|
||||||
|
analytics: { ga4MeasurementId: 'G-ABC123', metaPixelId: '' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('https://www.googletagmanager.com/gtag/js?id=G-ABC123')
|
||||||
|
expect(out.html).toContain("gtag('config', 'G-ABC123')")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Meta Pixel ID가 있으면 Pixel 스니펫을 삽입한다', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
...base,
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '1234567890' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('fbq(')
|
||||||
|
expect(out.html).toContain("fbq('init', '1234567890')")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('customHeadHtml이 있으면 head에 그대로 삽입한다', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
...base,
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '<meta name="robots" content="noindex">' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<meta name="robots" content="noindex">')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'T', description: 'D' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter CSS contrast/font tokens', () => {
|
||||||
|
test('contains base text color and responsive font adjustments', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
// base body text color token present
|
||||||
|
expect(out.css).toMatch(/body\{[^}]*color:\s*#0f172a/i)
|
||||||
|
// focus outline thickness and offset present
|
||||||
|
expect(out.css).toMatch(/outline:\s*3px\s*solid/i)
|
||||||
|
expect(out.css).toMatch(/outline-offset:\s*2px/i)
|
||||||
|
// responsive font size change for h1 in mobile media query
|
||||||
|
expect(out.css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter CSS tokens', () => {
|
||||||
|
test('includes focus outline, skip-link, and responsive MQ tokens', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(out.css).toMatch(/focus-visible|outline: 3px solid|outline-offset/)
|
||||||
|
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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: 'features', props: { items: ['One', 'Two'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - Features/Testimonials aria-labelledby', () => {
|
||||||
|
test('has aria-labelledby and matching heading ids', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
|
||||||
|
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Focus Tokens',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS focus-visible tokens smoke', () => {
|
||||||
|
test('outline uses 3px solid and hex color token', () => {
|
||||||
|
const { css } = exportPage(makePage())
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid/i)
|
||||||
|
// check presence of our hex color used for outline (example: #2563eb)
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})/)
|
||||||
|
// ensure focus-visible styles exist on common interactive elements
|
||||||
|
expect(css).toMatch(/button:focus|:focus-visible|a:focus|input:focus|textarea:focus|select:focus/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (withAction = false): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Form Test',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: withAction ? 'https://example.com/submit' : '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'] },
|
||||||
|
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'] },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
|
||||||
|
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'Form Test', description: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Exporter - form rendering', () => {
|
||||||
|
it('필드 라벨/required/옵션 및 스팸 방지 요소를 렌더링한다 (action 미지정)', () => {
|
||||||
|
const page = makePage(false)
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
// 필수 required
|
||||||
|
expect(out.html).toContain('name="name"')
|
||||||
|
expect(out.html).toContain('required')
|
||||||
|
expect(out.html).toContain('name="email"')
|
||||||
|
|
||||||
|
// select/radio 옵션 존재
|
||||||
|
expect(out.html).toContain('<select')
|
||||||
|
expect(out.html).toContain('<option value="A">A</option>')
|
||||||
|
expect(out.html).toContain('<input type="radio"')
|
||||||
|
|
||||||
|
// checkbox/textarea
|
||||||
|
expect(out.html).toContain('type="checkbox"')
|
||||||
|
expect(out.html).toContain('<textarea')
|
||||||
|
|
||||||
|
// 스팸 방지: honeypot + timestamp
|
||||||
|
expect(out.html).toContain('type="hidden"')
|
||||||
|
expect(out.html).toContain('name="_hp"')
|
||||||
|
expect(out.html).toContain('name="_ts"')
|
||||||
|
|
||||||
|
// action 미지정 → Sheets fallback 힌트
|
||||||
|
expect(out.html).toContain('data-sheets-fallback="true"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('action URL이 있으면 form에 action 속성이 포함된다', () => {
|
||||||
|
const page = makePage(true)
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('action="https://example.com/submit"')
|
||||||
|
expect(out.html).not.toContain('data-sheets-fallback="true"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (overrides: Partial<Page> = {}): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Validate',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true, pattern: '^[A-Za-z ]+$', maxLength: 30 },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'textarea', name: 'msg', label: 'Message', required: false, maxLength: 200 },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'Validate', description: 'desc' },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Exporter - field validation attributes', () => {
|
||||||
|
it('text에 pattern/maxLength, textarea에 maxLength를 렌더링한다', () => {
|
||||||
|
const page = makePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
// text: pattern + maxlength
|
||||||
|
expect(out.html).toContain('name="name"')
|
||||||
|
expect(out.html).toContain('pattern="^[A-Za-z ]+$"')
|
||||||
|
expect(out.html).toContain('maxlength="30"')
|
||||||
|
|
||||||
|
// textarea: maxlength
|
||||||
|
expect(out.html).toContain('<textarea')
|
||||||
|
expect(out.html).toContain('name="msg"')
|
||||||
|
expect(out.html).toContain('maxlength="200"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makeFullPage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Heading Levels All',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Hero Title', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'features', props: { items: ['Feat A', 'Feat B'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Ada', quote: 'Nice' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }, { q: 'Q2', a: 'A2' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Try', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter heading level consistency (full)', () => {
|
||||||
|
test('hero=h1, others=h2, faq items=h3', () => {
|
||||||
|
const { html } = exportPage(makeFullPage())
|
||||||
|
|
||||||
|
// hero
|
||||||
|
expect(html).toContain('<h1 id="hero-heading">Hero Title</h1>')
|
||||||
|
|
||||||
|
// features/testimonials/faq/cta sections labeled by h2
|
||||||
|
expect(html).toContain('<h2 id="features-heading">')
|
||||||
|
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
|
||||||
|
expect(html).toContain('<h2 id="testimonials-heading">')
|
||||||
|
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
|
||||||
|
expect(html).toContain('<h2 id="faq-heading">')
|
||||||
|
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
|
||||||
|
|
||||||
|
expect(html).toContain('<h2 id="cta-heading">')
|
||||||
|
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
|
||||||
|
|
||||||
|
// faq items use h3
|
||||||
|
expect(html).toMatch(/<h3[^>]*>Q1<\/h3>/)
|
||||||
|
expect(html).toMatch(/<h3[^>]*>Q2<\/h3>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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: 'My Landing',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#222222', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'features', props: { items: ['Fast', 'Reliable'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Start', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter heading levels', () => {
|
||||||
|
test('hero uses h1, sections use h2, faq questions use h3', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
// hero h1
|
||||||
|
expect(out.html).toMatch(/<section class="hero"[\s\S]*<h1[\s\S]*>Welcome<\/h1>/)
|
||||||
|
// features/testimonials/faq/cta use h2 for section titles
|
||||||
|
expect(out.html).toContain('<h2 id="features-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="faq-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="cta-heading">')
|
||||||
|
// faq item question headings h3
|
||||||
|
expect(out.html).toMatch(/<section class="faq"[\s\S]*<h3[\s\S]*>Q1<\/h3>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import type { Page, FormField } from '@/lib/schema/page'
|
||||||
|
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
|
export type Exported = { html: string; css: string; js: string }
|
||||||
|
export type ExportOptions = { assetManager?: AssetManager }
|
||||||
|
|
||||||
|
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||||
|
const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
||||||
|
return `
|
||||||
|
<section class="faq" aria-labelledby="faq-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="faq-heading">FAQ</h2>
|
||||||
|
<ul class="faq-list">
|
||||||
|
${items
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<li class="faq-item">
|
||||||
|
<h3 class="faq-q">${escapeHtml(it.q)}</h3>
|
||||||
|
<p class="faq-a">${escapeHtml(it.a)}</p>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeatures(section: { props: { items: string[] } }) {
|
||||||
|
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
||||||
|
return `
|
||||||
|
<section class="features" aria-labelledby="features-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="features-heading">Features</h2>
|
||||||
|
<ul class="features-list">
|
||||||
|
${items.map((it) => `<li class="feature-item">${escapeHtml(String(it))}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
||||||
|
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
||||||
|
? section.props.items
|
||||||
|
: []
|
||||||
|
return `
|
||||||
|
<section class="testimonials" aria-labelledby="testimonials-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="testimonials-heading">Testimonials</h2>
|
||||||
|
${items
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<figure class="testimonial">
|
||||||
|
<blockquote>“${escapeHtml(it.quote || '')}”</blockquote>
|
||||||
|
<figcaption>— ${escapeHtml(it.author || '')}</figcaption>
|
||||||
|
</figure>`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCta(section: { props: { text: string; href?: string } }) {
|
||||||
|
const { text, href } = section.props
|
||||||
|
return `
|
||||||
|
<section class="cta" aria-labelledby="cta-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="cta-heading">CTA</h2>
|
||||||
|
<a class="btn-cta" href="${escapeHtml(href || '#')}">${escapeHtml(text)}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
|
||||||
|
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
|
||||||
|
const { heading, subheading } = section.props
|
||||||
|
const am = opts?.assetManager
|
||||||
|
const imageUrlRaw: string = section.props.imageUrl
|
||||||
|
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||||
|
return `
|
||||||
|
<section class="hero" aria-labelledby="hero-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text">
|
||||||
|
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
|
||||||
|
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="media">
|
||||||
|
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(field: FormField) {
|
||||||
|
const pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
|
||||||
|
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
|
||||||
|
const common = `name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}`
|
||||||
|
switch (field.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'email':
|
||||||
|
case 'tel':
|
||||||
|
case 'date':
|
||||||
|
return `<label>${escapeHtml(field.label)}<input type="${field.type}" ${common} /></label>`
|
||||||
|
case 'textarea':
|
||||||
|
return `<label>${escapeHtml(field.label)}<textarea ${common}></textarea></label>`
|
||||||
|
case 'checkbox':
|
||||||
|
return `<label><input type="checkbox" ${common} />${escapeHtml(field.label)}</label>`
|
||||||
|
case 'radio': {
|
||||||
|
const { options } = field as Extract<FormField, { type: 'radio' }>
|
||||||
|
return `
|
||||||
|
<fieldset>
|
||||||
|
<legend>${escapeHtml(field.label)}</legend>
|
||||||
|
${options
|
||||||
|
.map(
|
||||||
|
(opt: string) =>
|
||||||
|
`<label><input type="radio" ${common} value="${escapeHtml(
|
||||||
|
opt
|
||||||
|
)}" />${escapeHtml(opt)}</label>`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</fieldset>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
const { options } = field as Extract<FormField, { type: 'select' }>
|
||||||
|
return `
|
||||||
|
<label>${escapeHtml(field.label)}
|
||||||
|
<select ${common}>
|
||||||
|
${options
|
||||||
|
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
|
||||||
|
.join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(page: Page) {
|
||||||
|
const { form } = page
|
||||||
|
const hasAction = !!(form.actionUrl && form.actionUrl.trim().length > 0)
|
||||||
|
const hp = form.spamProtection.honeypotFieldName
|
||||||
|
const dataFallback = hasAction ? '' : ' data-sheets-fallback="true"'
|
||||||
|
return `
|
||||||
|
<form method="post"${hasAction ? ` action="${escapeHtml(form.actionUrl!)}"` : ''}${dataFallback}>
|
||||||
|
<input type="hidden" name="${escapeHtml(hp)}" />
|
||||||
|
<input type="hidden" name="_ts" value="" />
|
||||||
|
${form.fields.map(renderField).join('\n')}
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHead(page: Page) {
|
||||||
|
const ogImage = page.seo.ogImage
|
||||||
|
const analyticsParts: string[] = []
|
||||||
|
const ga = page.analytics?.ga4MeasurementId
|
||||||
|
if (ga && ga.trim()) {
|
||||||
|
analyticsParts.push(`
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=${ga}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '${ga}');
|
||||||
|
</script>`)
|
||||||
|
}
|
||||||
|
const pixel = page.analytics?.metaPixelId
|
||||||
|
if (pixel && pixel.trim()) {
|
||||||
|
analyticsParts.push(`
|
||||||
|
<script>
|
||||||
|
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||||
|
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
|
||||||
|
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
|
||||||
|
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||||
|
'https://connect.facebook.net/en_US/fbevents.js');
|
||||||
|
fbq('init', '${pixel}');
|
||||||
|
fbq('track', 'PageView');
|
||||||
|
</script>`)
|
||||||
|
}
|
||||||
|
const custom = page.analytics?.customHeadHtml || ''
|
||||||
|
return `
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(page.seo.title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(page.seo.description)}" />
|
||||||
|
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}" />` : ''}
|
||||||
|
${analyticsParts.join('\n')}
|
||||||
|
${custom}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCss(page: Page) {
|
||||||
|
return `
|
||||||
|
:root{ --primary:${page.theme.primaryColor}; }
|
||||||
|
body{ font-family: ${page.theme.fontFamily}, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; margin:0; color:#0f172a }
|
||||||
|
a.skip-link{ position:absolute; left:-9999px; top:auto; width:1px; height:1px; overflow:hidden }
|
||||||
|
a.skip-link:focus{ position:absolute; left:1rem; top:1rem; width:auto; height:auto; padding:.5rem 1rem; background:#111827; color:#fff; border-radius:.375rem; z-index:1000 }
|
||||||
|
.container{ max-width: 960px; margin: 0 auto; padding: 2rem }
|
||||||
|
.hero .text{ margin-bottom: 1rem }
|
||||||
|
img{ max-width: 100%; height: auto }
|
||||||
|
label{ display:block; margin: .5rem 0 }
|
||||||
|
button{ background: var(--primary); color:#fff; border:none; padding:.75rem 1rem; border-radius:.5rem }
|
||||||
|
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
|
||||||
|
.faq-list{ list-style:none; padding:0; margin:0 }
|
||||||
|
.faq-item{ margin: .75rem 0 }
|
||||||
|
.faq-q{ font-size: 1rem; font-weight:600; }
|
||||||
|
.faq-a{ color:#334155 }
|
||||||
|
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
|
||||||
|
.features{ background:#f8fafc }
|
||||||
|
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
|
||||||
|
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem }
|
||||||
|
.testimonials{ background:#ffffff }
|
||||||
|
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
|
||||||
|
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
|
||||||
|
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
||||||
|
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
||||||
|
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJs() {
|
||||||
|
// timestamp 채우기(스팸 방지용 제출 지연 체크용 데이터)
|
||||||
|
return `
|
||||||
|
(function(){
|
||||||
|
var ts = document.querySelector('input[name="_ts"]');
|
||||||
|
if(ts){ ts.value = String(Date.now()); }
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
||||||
|
const sections = page.sections
|
||||||
|
.map((s: Page['sections'][number]) => {
|
||||||
|
if (s.type === 'hero') return renderHero(s, opts)
|
||||||
|
if (s.type === 'faq') return renderFaq(s)
|
||||||
|
if (s.type === 'cta') return renderCta(s)
|
||||||
|
if (s.type === 'features') return renderFeatures(s)
|
||||||
|
if (s.type === 'testimonials') return renderTestimonials(s)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const formHtml = renderForm(page)
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="${escapeHtml(page.locale)}">
|
||||||
|
<head>
|
||||||
|
${buildHead(page)}
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="#main" class="skip-link">Skip to content</a>
|
||||||
|
<main id="main" role="main">
|
||||||
|
${sections}
|
||||||
|
${formHtml}
|
||||||
|
</main>
|
||||||
|
<script>${buildJs()}</script>
|
||||||
|
<style>${buildCss(page)}</style>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return { html, css: buildCss(page), js: buildJs() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
|
const makeLocalImagePage = async () => {
|
||||||
|
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
|
||||||
|
const hero = await am.add({ name: 'hero.png', type: 'image/png', bytes: new Uint8Array([1,2,3]) })
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
title: 'Local Image',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
props: { heading: 'Hi', subheading: '', imageUrl: `local:${hero.originalName}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'Local Image', description: 'd' },
|
||||||
|
})
|
||||||
|
return { am, page }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HTML Exporter - asset rewrite', () => {
|
||||||
|
it('local: 프리픽스 이미지는 assets/<hash>.<ext>로 치환된다', async () => {
|
||||||
|
const { am, page } = await makeLocalImagePage()
|
||||||
|
const out = exportPage(page, { assetManager: am })
|
||||||
|
|
||||||
|
// assets 경로가 HTML 내 포함되어야 함
|
||||||
|
expect(out.html).toMatch(/assets\/[a-f0-9]{8}\.png/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makeSamplePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Sample Landing',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
props: {
|
||||||
|
heading: 'Welcome',
|
||||||
|
subheading: 'Build fast',
|
||||||
|
imageUrl: 'https://cdn.example.com/hero.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '' },
|
||||||
|
seo: { title: 'Sample Landing', description: 'desc', ogImage: 'https://cdn.example.com/og.png' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('HTML Exporter', () => {
|
||||||
|
it('페이지를 정적 HTML/CSS/JS로 내보낸다', () => {
|
||||||
|
const page = makeSamplePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
expect(typeof out.html).toBe('string')
|
||||||
|
expect(typeof out.css).toBe('string')
|
||||||
|
expect(typeof out.js).toBe('string')
|
||||||
|
|
||||||
|
// 기본 요소 포함
|
||||||
|
expect(out.html).toContain('<!DOCTYPE html>')
|
||||||
|
expect(out.html).toContain('<html lang="en">')
|
||||||
|
expect(out.html).toContain('<title>Sample Landing</title>')
|
||||||
|
|
||||||
|
// OG/SEO
|
||||||
|
expect(out.html).toContain('meta property="og:title"')
|
||||||
|
expect(out.html).toContain('meta property="og:image"')
|
||||||
|
|
||||||
|
// Hero 섹션 렌더링
|
||||||
|
expect(out.html).toContain('Welcome')
|
||||||
|
expect(out.html).toContain('Build fast')
|
||||||
|
expect(out.html).toContain('https://cdn.example.com/hero.png')
|
||||||
|
|
||||||
|
// Form 렌더링 및 스팸방지 요소
|
||||||
|
expect(out.html).toContain('name="name"')
|
||||||
|
expect(out.html).toContain('name="email"')
|
||||||
|
expect(out.html).toContain('name="agree"')
|
||||||
|
expect(out.html).toContain('type="hidden"') // honeypot 또는 타임스탬프
|
||||||
|
|
||||||
|
// actionUrl 미지정이면 data-hint 포함(향후 Sheets 연결 힌트)
|
||||||
|
expect(out.html).toContain('data-sheets-fallback="true"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파일 input은 생성하지 않는다', () => {
|
||||||
|
const page = makeSamplePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).not.toMatch(/type=\"file\"/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
describe('Exporter - OG image meta', () => {
|
||||||
|
test('includes og:image meta when seo.ogImage is provided', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc', ogImage: 'https://example.com/og.png' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<meta property="og:image" content="https://example.com/og.png" />')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('omits og:image meta when seo.ogImage is empty', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).not.toContain('og:image')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Snapshot',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'features', props: { items: ['One', 'Two'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
{ 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 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter regression snapshot (markers only)', () => {
|
||||||
|
test('combined markers for headings/aria/responsive exist', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const { html, css } = out
|
||||||
|
// headings/aria markers
|
||||||
|
expect(html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
|
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(html).toContain('<h2 id="features-heading">')
|
||||||
|
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(html).toContain('<h2 id="testimonials-heading">')
|
||||||
|
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
|
||||||
|
expect(html).toContain('<h2 id="faq-heading">')
|
||||||
|
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
|
||||||
|
expect(html).toContain('<h2 id="cta-heading">')
|
||||||
|
// responsive/a11y CSS markers
|
||||||
|
expect(css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
expect(css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makeBase(overrides: Partial<Page>): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
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 - Features and Testimonials sections', () => {
|
||||||
|
test('renders features list with items and classes', () => {
|
||||||
|
const sections = ([
|
||||||
|
{ type: 'features', props: { items: ['One', 'Two', 'Three'] } },
|
||||||
|
] as unknown) as Page['sections']
|
||||||
|
const page = makeBase({ sections })
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(out.html).toContain('<ul class="features-list">')
|
||||||
|
expect(out.html).toContain('<li class="feature-item">One</li>')
|
||||||
|
expect(out.html).toContain('<li class="feature-item">Two</li>')
|
||||||
|
expect(out.html).toContain('<li class="feature-item">Three</li>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders testimonials with figure/blockquote/figcaption structure', () => {
|
||||||
|
const sections = ([
|
||||||
|
{ type: 'testimonials', props: { items: [
|
||||||
|
{ author: 'Jane', quote: 'Great!' },
|
||||||
|
{ author: 'John', quote: 'Nice.' },
|
||||||
|
] } },
|
||||||
|
] as unknown) as Page['sections']
|
||||||
|
const page = makeBase({ sections })
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<figure class="testimonial">')
|
||||||
|
expect(out.html).toContain('<blockquote>“Great!”</blockquote>')
|
||||||
|
expect(out.html).toContain('<figcaption>— Jane</figcaption>')
|
||||||
|
expect(out.html).toContain('<blockquote>“Nice.”</blockquote>')
|
||||||
|
expect(out.html).toContain('<figcaption>— John</figcaption>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Sections',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'faq', props: { items: [ { q: 'Q1?', a: 'A1.' }, { q: 'Q2?', a: 'A2.' } ] } },
|
||||||
|
{ type: 'cta', props: { text: 'Get Started', href: '#start' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'Sections', description: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Exporter - FAQ/CTA sections', () => {
|
||||||
|
it('FAQ와 CTA 섹션을 렌더링한다', () => {
|
||||||
|
const page = makePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
// FAQ
|
||||||
|
expect(out.html).toContain('Q1?')
|
||||||
|
expect(out.html).toContain('A1.')
|
||||||
|
expect(out.html).toContain('Q2?')
|
||||||
|
expect(out.html).toContain('A2.')
|
||||||
|
|
||||||
|
// CTA
|
||||||
|
expect(out.html).toContain('Get Started')
|
||||||
|
expect(out.html).toContain('#start')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'A11y Tokens',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A11y color/typography token smoke', () => {
|
||||||
|
test('base text color, font-family and mobile h1 size markers exist', () => {
|
||||||
|
const { css } = exportPage(makePage())
|
||||||
|
|
||||||
|
// base text color token presence
|
||||||
|
expect(css.replace(/\s+/g, ' ')).toMatch(/body\{[^}]*color:\s*#0f172a/i)
|
||||||
|
|
||||||
|
// font-family token (contains "Inter" and common fallbacks)
|
||||||
|
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter/i)
|
||||||
|
|
||||||
|
// mobile h1 size within max-width:640px media query
|
||||||
|
expect(css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import { buildZip } from '@/lib/exporter/zip'
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder()
|
||||||
|
|
||||||
|
describe('ZIP 유틸', () => {
|
||||||
|
it('index.html / styles.css / app.js 와 assets/* 를 포함한 ZIP을 생성한다', async () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><title>x</title></head><body>hi</body></html>'
|
||||||
|
const css = 'body{color:#000}'
|
||||||
|
const js = 'console.log(1)'
|
||||||
|
const assets: Record<string, Uint8Array> = {
|
||||||
|
'assets/a1.png': new Uint8Array([1,2,3]),
|
||||||
|
'assets/b2.svg': textEncoder.encode('<svg></svg>'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBytes = await buildZip({ html, css, js, assets })
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(zipBytes)
|
||||||
|
const names = Object.keys(zip.files)
|
||||||
|
|
||||||
|
expect(names).toContain('index.html')
|
||||||
|
expect(names).toContain('styles.css')
|
||||||
|
expect(names).toContain('app.js')
|
||||||
|
expect(names).toContain('assets/a1.png')
|
||||||
|
expect(names).toContain('assets/b2.svg')
|
||||||
|
|
||||||
|
const htmlContent = await zip.files['index.html'].async('string')
|
||||||
|
expect(htmlContent).toContain('<title>x</title>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
|
||||||
|
export type BuildZipInput = {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
assets?: Record<string, Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
zip.file('index.html', input.html)
|
||||||
|
zip.file('styles.css', input.css)
|
||||||
|
zip.file('app.js', input.js)
|
||||||
|
if (input.assets) {
|
||||||
|
for (const [path, bytes] of Object.entries(input.assets)) {
|
||||||
|
// In Node/Vitest, use Buffer for robust binary handling
|
||||||
|
const buf = Buffer.from(bytes)
|
||||||
|
zip.file(path, buf as unknown as Buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = await zip.generateAsync({ type: 'uint8array' })
|
||||||
|
return content
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { locales, type AppLocale } from '@/lib/i18n/locales'
|
||||||
|
|
||||||
|
export async function getMessages(locale: AppLocale) {
|
||||||
|
const loc = locales.includes(locale) ? locale : 'en'
|
||||||
|
const messages = (await import(`@/messages/${loc}.json`)).default
|
||||||
|
return messages as Record<string, string>
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createI18n } from '@/lib/i18n'
|
||||||
|
|
||||||
|
describe('i18n util', () => {
|
||||||
|
const i18n = createI18n({
|
||||||
|
defaultLocale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: { hello: 'Hello {name}', onlyEn: 'Only EN' },
|
||||||
|
ko: { hello: '안녕하세요 {name}' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
it('로케일별 번역과 변수 치환을 지원한다', () => {
|
||||||
|
expect(i18n.t('en', 'hello', { name: 'Jay' })).toBe('Hello Jay')
|
||||||
|
expect(i18n.t('ko', 'hello', { name: '제이' })).toBe('안녕하세요 제이')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('키가 없으면 기본 로케일로 폴백한다', () => {
|
||||||
|
expect(i18n.t('ko', 'onlyEn')).toBe('Only EN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('완전히 없는 키는 키 그대로 반환한다', () => {
|
||||||
|
expect(i18n.t('en', 'missing.key')).toBe('missing.key')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
type Messages = Record<string, Record<string, string>>
|
||||||
|
|
||||||
|
export function createI18n({ defaultLocale, messages }: { defaultLocale: string; messages: Messages }) {
|
||||||
|
function format(template: string, params?: Record<string, string | number>) {
|
||||||
|
if (!params) return template
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : `{${k}}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessage(locale: string, key: string): string | undefined {
|
||||||
|
const m = messages[locale] || {}
|
||||||
|
if (m[key] !== undefined) return m[key]
|
||||||
|
const base = messages[defaultLocale] || {}
|
||||||
|
return base[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(locale: string, key: string, params?: Record<string, string | number>): string {
|
||||||
|
const msg = getMessage(locale, key)
|
||||||
|
if (typeof msg === 'string') return format(msg, params)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
return { t }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const locales = ['en', 'ko'] as const
|
||||||
|
export type AppLocale = typeof locales[number]
|
||||||
|
export const defaultLocale: AppLocale = 'en'
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { generateAppsScriptTemplate } from '@/lib/integrations/sheets'
|
||||||
|
|
||||||
|
describe('Google Sheets Apps Script 템플릿', () => {
|
||||||
|
it('시트 ID와 허용 오리진으로 Apps Script 코드와 가이드를 생성한다', () => {
|
||||||
|
const out = generateAppsScriptTemplate({
|
||||||
|
sheetId: '1AbcDEFghiJKL_sheet_id',
|
||||||
|
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
|
||||||
|
minSubmitSeconds: 2,
|
||||||
|
honeypotFieldName: '_hp',
|
||||||
|
sheetName: 'Responses',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(typeof out.code).toBe('string')
|
||||||
|
expect(typeof out.readme).toBe('string')
|
||||||
|
|
||||||
|
// 필수 키워드 포함 검증
|
||||||
|
expect(out.code).toContain('function doPost(e)')
|
||||||
|
expect(out.code).toContain('SpreadsheetApp.openById')
|
||||||
|
expect(out.code).toContain('e.postData')
|
||||||
|
expect(out.code).toContain('ContentService.createTextOutput')
|
||||||
|
expect(out.code).toContain('Access-Control-Allow-Origin')
|
||||||
|
|
||||||
|
// 환경설정 치환값 반영
|
||||||
|
expect(out.code).toContain('1AbcDEFghiJKL_sheet_id')
|
||||||
|
expect(out.code).toContain('Responses')
|
||||||
|
expect(out.code).toContain('_hp')
|
||||||
|
expect(out.code).toContain('minSubmitSeconds')
|
||||||
|
|
||||||
|
// 배포 가이드에 웹 앱 배포 안내 포함
|
||||||
|
expect(out.readme).toMatch(/Deploy.*as Web app/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
type TemplateParams = {
|
||||||
|
sheetId: string
|
||||||
|
allowedOrigins: string[]
|
||||||
|
minSubmitSeconds: number
|
||||||
|
honeypotFieldName: string
|
||||||
|
sheetName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAppsScriptTemplate(params: TemplateParams) {
|
||||||
|
const { sheetId, allowedOrigins, minSubmitSeconds, honeypotFieldName, sheetName = 'Responses' } = params
|
||||||
|
|
||||||
|
const code = `/**
|
||||||
|
* Apps Script Web App for accepting HTML form submissions and writing to Google Sheets.
|
||||||
|
* Deployed as Web App with Anyone (even anonymous) or Anyone within domain access, as needed.
|
||||||
|
*/
|
||||||
|
const CONFIG = {
|
||||||
|
SHEET_ID: '${sheetId}',
|
||||||
|
SHEET_NAME: '${sheetName}',
|
||||||
|
ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)},
|
||||||
|
minSubmitSeconds: ${minSubmitSeconds},
|
||||||
|
HONEYPOT: '${honeypotFieldName}',
|
||||||
|
};
|
||||||
|
|
||||||
|
function doOptions(e) {
|
||||||
|
return corsResponse('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPost(e) {
|
||||||
|
try {
|
||||||
|
const origin = (e?.parameter?.origin) || (e?.headers && e.headers['Origin']) || '';
|
||||||
|
const now = Date.now();
|
||||||
|
const params = parseBody(e);
|
||||||
|
|
||||||
|
// CORS check
|
||||||
|
enforceCors(origin);
|
||||||
|
|
||||||
|
// Spam protection: honeypot must be empty
|
||||||
|
if (params[CONFIG.HONEYPOT]) {
|
||||||
|
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spam protection: minimum submit time
|
||||||
|
const ts = Number(params['_ts'] || 0);
|
||||||
|
if (!isFinite(ts) || now - ts < CONFIG.minSubmitSeconds * 1000) {
|
||||||
|
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to sheet
|
||||||
|
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
|
||||||
|
const sh = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.getSheets()[0];
|
||||||
|
const entries = Object.keys(params).filter(k => k !== CONFIG.HONEYPOT);
|
||||||
|
const values = entries.map(k => String(params[k]));
|
||||||
|
sh.appendRow([new Date()].concat(values));
|
||||||
|
|
||||||
|
return corsResponse(JSON.stringify({ ok: true }), 200, origin);
|
||||||
|
} catch (err) {
|
||||||
|
return corsResponse(JSON.stringify({ ok: false, error: String(err) }), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(e) {
|
||||||
|
// Accept form-urlencoded or JSON
|
||||||
|
if (e?.postData?.type === 'application/json') {
|
||||||
|
try { return JSON.parse(e.postData.contents || '{}'); } catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
// Parse form-urlencoded
|
||||||
|
const raw = (e?.postData?.contents || '');
|
||||||
|
const out = {};
|
||||||
|
raw.split('&').forEach(p => {
|
||||||
|
const [k, v] = p.split('=');
|
||||||
|
if (k) out[decodeURIComponent(k)] = decodeURIComponent((v || '').replace(/\+/g, ' '));
|
||||||
|
});
|
||||||
|
// Also include e.parameter for safety
|
||||||
|
if (e?.parameter) {
|
||||||
|
Object.keys(e.parameter).forEach(k => { if (!(k in out)) out[k] = e.parameter[k]; });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceCors(origin) {
|
||||||
|
if (!CONFIG.ALLOWED_ORIGINS || CONFIG.ALLOWED_ORIGINS.length === 0) return;
|
||||||
|
if (CONFIG.ALLOWED_ORIGINS.indexOf(origin) === -1) {
|
||||||
|
throw new Error('CORS: Origin not allowed: ' + origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function corsResponse(body, status, origin) {
|
||||||
|
const output = ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
const resp = HtmlService.createHtmlOutput().getResponse();
|
||||||
|
resp.setContent(output.getContent());
|
||||||
|
resp.setResponseCode(status || 200);
|
||||||
|
resp.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||||
|
resp.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
resp.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const readme = `# Google Apps Script Web App (Google Sheets 저장)
|
||||||
|
|
||||||
|
이 스크립트는 HTML 폼 제출을 받아 Google 스프레드시트에 행을 추가합니다.
|
||||||
|
|
||||||
|
## 준비물
|
||||||
|
- Google 스프레드시트 생성 후 시트 ID 확인(주소창의 /d/ 와 /edit 사이)
|
||||||
|
- Apps Script 에디터 열기(스프레드시트 -> 확장 프로그램 -> Apps Script)
|
||||||
|
|
||||||
|
## 사용 방법
|
||||||
|
1. 새 프로젝트 생성 후, Code.gs 에 아래 코드 붙여넣기
|
||||||
|
2. CONFIG 내 값을 편집
|
||||||
|
- SHEET_ID: ${sheetId}
|
||||||
|
- SHEET_NAME: ${sheetName}
|
||||||
|
- ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)}
|
||||||
|
- MIN_SUBMIT_SECONDS: ${minSubmitSeconds}
|
||||||
|
- HONEYPOT: ${honeypotFieldName}
|
||||||
|
3. 배포
|
||||||
|
- 상단 메뉴: Deploy -> Deploy as Web app
|
||||||
|
- 새 배포 -> Entry point: doPost
|
||||||
|
- Access: Anyone (권장) 또는 필요 정책에 맞게 설정
|
||||||
|
- Deploy를 눌러 Web app URL 획득
|
||||||
|
4. 빌더에서 폼 action URL 에 위 Web app URL 입력
|
||||||
|
|
||||||
|
## CORS 및 스팸 방지
|
||||||
|
- Access-Control-Allow-Origin 헤더가 설정됩니다.
|
||||||
|
- 허용 오리진만 통과하도록 enforceCors를 사용합니다.
|
||||||
|
- honeypot 필드와 제출 타임스탬프(_ts)로 기본 스팸 방지 동작을 합니다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- doPost, SpreadsheetApp 관련 호출은 Apps Script 런타임에서 동작합니다.
|
||||||
|
`
|
||||||
|
|
||||||
|
return { code, readme }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
// TDD: 아직 구현되지 않은 스키마를 가정
|
||||||
|
import { pageSchema } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
describe('pageSchema', () => {
|
||||||
|
it('유효한 랜딩 페이지 스키마를 통과시킨다', () => {
|
||||||
|
const valid = {
|
||||||
|
title: 'My Landing',
|
||||||
|
locale: 'en',
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#0ea5e9',
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
props: {
|
||||||
|
heading: 'Hello',
|
||||||
|
subheading: 'World',
|
||||||
|
imageUrl: 'https://cdn.example.com/hero.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
form: {
|
||||||
|
actionUrl: '', // 빈 문자열이면 Sheets로 제출(향후 로직)
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
|
||||||
|
],
|
||||||
|
spamProtection: {
|
||||||
|
honeypotFieldName: '_hp',
|
||||||
|
minSubmitSeconds: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
ga4MeasurementId: '',
|
||||||
|
metaPixelId: '',
|
||||||
|
customHeadHtml: '',
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
title: 'My Landing',
|
||||||
|
description: 'Best product',
|
||||||
|
ogImage: 'https://cdn.example.com/og.png',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = pageSchema.parse(valid)
|
||||||
|
expect(parsed.title).toBe('My Landing')
|
||||||
|
expect(parsed.sections.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파일 업로드 필드는 허용하지 않는다', () => {
|
||||||
|
const invalid = {
|
||||||
|
title: 'No File Please',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
// 파일 타입은 금지
|
||||||
|
{ type: 'file', name: 'attachment', label: 'Upload' },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
analytics: {},
|
||||||
|
seo: { title: 'x', description: '', ogImage: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => pageSchema.parse(invalid)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const hexColor = z
|
||||||
|
.string()
|
||||||
|
.regex(/^#([0-9a-fA-F]{3}){1,2}$/i, 'invalid hex color')
|
||||||
|
|
||||||
|
const url = z.string().url()
|
||||||
|
|
||||||
|
const sectionHeroSchema = z.object({
|
||||||
|
type: z.literal('hero'),
|
||||||
|
props: z.object({
|
||||||
|
heading: z.string().min(1),
|
||||||
|
subheading: z.string().optional().default(''),
|
||||||
|
imageUrl: url,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionFaqSchema = z.object({
|
||||||
|
type: z.literal('faq'),
|
||||||
|
props: z.object({
|
||||||
|
items: z.array(z.object({ q: z.string().min(1), a: z.string().min(1) })).min(1),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionCtaSchema = z.object({
|
||||||
|
type: z.literal('cta'),
|
||||||
|
props: z.object({ text: z.string().min(1), href: z.string().optional().default('#') }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionFeaturesSchema = z.object({
|
||||||
|
type: z.literal('features'),
|
||||||
|
props: z.object({ items: z.array(z.string().min(1)).min(1) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionTestimonialsSchema = z.object({
|
||||||
|
type: z.literal('testimonials'),
|
||||||
|
props: z.object({ items: z.array(z.object({ author: z.string().min(1), quote: z.string().min(1) })).min(1) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 확장: hero, faq, cta, features, testimonials 허용
|
||||||
|
const sectionSchema = z.union([
|
||||||
|
sectionHeroSchema,
|
||||||
|
sectionFaqSchema,
|
||||||
|
sectionCtaSchema,
|
||||||
|
sectionFeaturesSchema,
|
||||||
|
sectionTestimonialsSchema,
|
||||||
|
])
|
||||||
|
|
||||||
|
const baseField = {
|
||||||
|
name: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
required: z.boolean().optional().default(false),
|
||||||
|
pattern: z.string().optional(),
|
||||||
|
maxLength: z.number().int().positive().optional(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const textField = z.object({ type: z.literal('text'), ...baseField })
|
||||||
|
const emailField = z.object({ type: z.literal('email'), ...baseField })
|
||||||
|
const telField = z.object({ type: z.literal('tel'), ...baseField })
|
||||||
|
const textareaField = z.object({ type: z.literal('textarea'), ...baseField })
|
||||||
|
const dateField = z.object({ type: z.literal('date'), ...baseField })
|
||||||
|
const checkboxField = z.object({ type: z.literal('checkbox'), ...baseField })
|
||||||
|
const radioField = z.object({
|
||||||
|
type: z.literal('radio'),
|
||||||
|
...baseField,
|
||||||
|
options: z.array(z.string().min(1)).min(1),
|
||||||
|
})
|
||||||
|
const selectField = z.object({
|
||||||
|
type: z.literal('select'),
|
||||||
|
...baseField,
|
||||||
|
options: z.array(z.string().min(1)).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 파일 업로드는 제외
|
||||||
|
export const formFieldSchema = z.union([
|
||||||
|
textField,
|
||||||
|
emailField,
|
||||||
|
telField,
|
||||||
|
textareaField,
|
||||||
|
dateField,
|
||||||
|
checkboxField,
|
||||||
|
radioField,
|
||||||
|
selectField,
|
||||||
|
])
|
||||||
|
|
||||||
|
export const pageSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
locale: z.string().min(2),
|
||||||
|
theme: z.object({
|
||||||
|
primaryColor: hexColor,
|
||||||
|
fontFamily: z.string().min(1),
|
||||||
|
}),
|
||||||
|
sections: z.array(sectionSchema),
|
||||||
|
form: z.object({
|
||||||
|
actionUrl: z.string().optional().default(''),
|
||||||
|
fields: z.array(formFieldSchema).min(0),
|
||||||
|
spamProtection: z.object({
|
||||||
|
honeypotFieldName: z.string().min(1).default('_hp'),
|
||||||
|
minSubmitSeconds: z.number().min(0).default(2),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
analytics: z
|
||||||
|
.object({
|
||||||
|
ga4MeasurementId: z.string().optional().default(''),
|
||||||
|
metaPixelId: z.string().optional().default(''),
|
||||||
|
customHeadHtml: z.string().optional().default(''),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({ ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }),
|
||||||
|
seo: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
ogImage: url.optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Page = z.infer<typeof pageSchema>
|
||||||
|
export type FormField = z.infer<typeof formFieldSchema>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createFormStore, type FormFieldInput, type FormState, type FormField } from '@/lib/state/formStore'
|
||||||
|
|
||||||
|
const text = (name: string, label = name): FormFieldInput => ({ type: 'text', name, label, required: false })
|
||||||
|
|
||||||
|
describe('form store', () => {
|
||||||
|
it('필드 추가/삭제/이동/업데이트와 action URL, spam 옵션을 관리한다', () => {
|
||||||
|
const useForm = createFormStore()
|
||||||
|
|
||||||
|
// 초기
|
||||||
|
let s: FormState = useForm.getState()
|
||||||
|
expect(s.fields.length).toBe(0)
|
||||||
|
expect(s.actionUrl).toBe('')
|
||||||
|
expect(s.spam.honeypotFieldName).toBe('_hp')
|
||||||
|
|
||||||
|
// 추가
|
||||||
|
useForm.getState().addField(text('name', 'Name'))
|
||||||
|
useForm.getState().addField(text('email', 'Email'))
|
||||||
|
useForm.getState().addField({ type: 'select', name: 'plan', label: 'Plan', required: true, options: ['A','B'] })
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields.map((f: FormField) => f.name)).toEqual(['name','email','plan'])
|
||||||
|
|
||||||
|
// 이동 (0 -> 2)
|
||||||
|
useForm.getState().moveField(0, 2)
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields.map((f: FormField) => f.name)).toEqual(['email','plan','name'])
|
||||||
|
|
||||||
|
// 업데이트
|
||||||
|
useForm.getState().updateField(1, { label: 'Pricing Plan', required: false })
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields[1].label).toBe('Pricing Plan')
|
||||||
|
expect(s.fields[1].required).toBe(false)
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
useForm.getState().removeField(0)
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields.map((f: FormField) => f.name)).toEqual(['plan','name'])
|
||||||
|
|
||||||
|
// action & spam
|
||||||
|
useForm.getState().setActionUrl('https://example.com/form')
|
||||||
|
useForm.getState().setSpam({ honeypotFieldName: '_trap', minSubmitSeconds: 3 })
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.actionUrl).toBe('https://example.com/form')
|
||||||
|
expect(s.spam.honeypotFieldName).toBe('_trap')
|
||||||
|
expect(s.spam.minSubmitSeconds).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createStore } from 'zustand/vanilla'
|
||||||
|
|
||||||
|
export type BaseField = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
pattern?: string
|
||||||
|
maxLength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextField = BaseField & { type: 'text' | 'email' | 'tel' | 'date' | 'textarea' }
|
||||||
|
export type CheckboxField = BaseField & { type: 'checkbox' }
|
||||||
|
export type RadioField = BaseField & { type: 'radio'; options: string[] }
|
||||||
|
export type SelectField = BaseField & { type: 'select'; options: string[] }
|
||||||
|
|
||||||
|
export type FormField = TextField | CheckboxField | RadioField | SelectField
|
||||||
|
|
||||||
|
export type FormFieldInput = FormField
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
fields: FormField[]
|
||||||
|
actionUrl: string
|
||||||
|
spam: { honeypotFieldName: string; minSubmitSeconds: number }
|
||||||
|
addField: (f: FormFieldInput) => void
|
||||||
|
moveField: (from: number, to: number) => void
|
||||||
|
removeField: (index: number) => void
|
||||||
|
updateField: (index: number, patch: Partial<FormField>) => void
|
||||||
|
setActionUrl: (url: string) => void
|
||||||
|
setSpam: (s: { honeypotFieldName?: string; minSubmitSeconds?: number }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormStore() {
|
||||||
|
return createStore<FormState>((set) => ({
|
||||||
|
fields: [],
|
||||||
|
actionUrl: '',
|
||||||
|
spam: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
addField: (f) => set((st) => ({ fields: [...st.fields, f] })),
|
||||||
|
moveField: (from, to) =>
|
||||||
|
set((st) => {
|
||||||
|
const arr = [...st.fields]
|
||||||
|
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
|
||||||
|
const [spliced] = arr.splice(from, 1)
|
||||||
|
arr.splice(to, 0, spliced)
|
||||||
|
return { fields: arr }
|
||||||
|
}),
|
||||||
|
removeField: (index) =>
|
||||||
|
set((st) => {
|
||||||
|
if (index < 0 || index >= st.fields.length) return st
|
||||||
|
const arr = st.fields.slice()
|
||||||
|
arr.splice(index, 1)
|
||||||
|
return { fields: arr }
|
||||||
|
}),
|
||||||
|
updateField: (index, patch) =>
|
||||||
|
set((st) => {
|
||||||
|
if (index < 0 || index >= st.fields.length) return st
|
||||||
|
const next = st.fields.slice()
|
||||||
|
next[index] = { ...next[index], ...patch } as FormField
|
||||||
|
return { fields: next }
|
||||||
|
}),
|
||||||
|
setActionUrl: (url) => set({ actionUrl: url }),
|
||||||
|
setSpam: (s) =>
|
||||||
|
set((st) => ({
|
||||||
|
spam: {
|
||||||
|
honeypotFieldName: s.honeypotFieldName ?? st.spam.honeypotFieldName,
|
||||||
|
minSubmitSeconds: s.minSubmitSeconds ?? st.spam.minSubmitSeconds,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createBuilderStore, type Section } from '@/lib/state/store'
|
||||||
|
|
||||||
|
describe('builder store (zustand)', () => {
|
||||||
|
it('섹션 추가/이동/삭제와 로케일 변경을 지원한다', () => {
|
||||||
|
const useStore = createBuilderStore()
|
||||||
|
const s1: Section = { type: 'hero', props: { heading: 'H1' } }
|
||||||
|
const s2: Section = { type: 'faq', props: { items: [] } }
|
||||||
|
const s3: Section = { type: 'cta', props: { text: 'Go' } }
|
||||||
|
|
||||||
|
// 초기 상태
|
||||||
|
expect(useStore.getState().locale).toBe('en')
|
||||||
|
expect(useStore.getState().sections.length).toBe(0)
|
||||||
|
|
||||||
|
// 추가
|
||||||
|
useStore.getState().addSection(s1)
|
||||||
|
useStore.getState().addSection(s2)
|
||||||
|
useStore.getState().addSection(s3)
|
||||||
|
expect(useStore.getState().sections.map(s => s.type)).toEqual(['hero','faq','cta'])
|
||||||
|
|
||||||
|
// 이동 (index 0 -> 2)
|
||||||
|
useStore.getState().moveSection(0, 2)
|
||||||
|
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','cta','hero'])
|
||||||
|
|
||||||
|
// 삭제 (index 1: 'cta')
|
||||||
|
useStore.getState().removeSection(1)
|
||||||
|
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','hero'])
|
||||||
|
|
||||||
|
// 로케일 변경
|
||||||
|
useStore.getState().setLocale('ko')
|
||||||
|
expect(useStore.getState().locale).toBe('ko')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createStore } from 'zustand/vanilla'
|
||||||
|
|
||||||
|
export type HeroSection = { type: 'hero'; props: { heading?: string; subheading?: string } }
|
||||||
|
export type FaqSection = { type: 'faq'; props: { items: Array<{ q: string; a: string }> } }
|
||||||
|
export type CtaSection = { type: 'cta'; props: { text: string; href?: string } }
|
||||||
|
export type FeaturesSection = { type: 'features'; props: { items: string[] } }
|
||||||
|
export type TestimonialsSection = { type: 'testimonials'; props: { items: Array<{ author: string; quote: string }> } }
|
||||||
|
|
||||||
|
export type Section = HeroSection | FaqSection | CtaSection | FeaturesSection | TestimonialsSection
|
||||||
|
|
||||||
|
export type BuilderState = {
|
||||||
|
locale: string
|
||||||
|
sections: Section[]
|
||||||
|
addSection: (s: Section) => void
|
||||||
|
moveSection: (from: number, to: number) => void
|
||||||
|
removeSection: (index: number) => void
|
||||||
|
setLocale: (loc: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBuilderStore() {
|
||||||
|
return createStore<BuilderState>((set, get) => ({
|
||||||
|
locale: 'en',
|
||||||
|
sections: [],
|
||||||
|
addSection: (s) => set((st) => ({ sections: [...st.sections, s] })),
|
||||||
|
moveSection: (from, to) =>
|
||||||
|
set((st) => {
|
||||||
|
const arr = [...st.sections]
|
||||||
|
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
|
||||||
|
const [spliced] = arr.splice(from, 1)
|
||||||
|
arr.splice(to, 0, spliced)
|
||||||
|
return { sections: arr }
|
||||||
|
}),
|
||||||
|
removeSection: (index) =>
|
||||||
|
set((st) => {
|
||||||
|
if (index < 0 || index >= st.sections.length) return st
|
||||||
|
const arr = st.sections.slice()
|
||||||
|
arr.splice(index, 1)
|
||||||
|
return { sections: arr }
|
||||||
|
}),
|
||||||
|
setLocale: (loc) => set({ locale: loc }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { move } from './reorder'
|
||||||
|
|
||||||
|
describe('reorder.move', () => {
|
||||||
|
it('같은 배열 인스턴스를 변형하지 않고 from→to로 아이템을 이동한다', () => {
|
||||||
|
const src = ['a', 'b', 'c', 'd']
|
||||||
|
const out = move(src, 1, 3)
|
||||||
|
expect(src).toEqual(['a', 'b', 'c', 'd'])
|
||||||
|
expect(out).toEqual(['a', 'c', 'd', 'b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('경계 밖 인덱스는 원본을 그대로 반환한다', () => {
|
||||||
|
const src = ['x', 'y']
|
||||||
|
expect(move(src, -1, 1)).toBe(src)
|
||||||
|
expect(move(src, 0, 5)).toBe(src)
|
||||||
|
expect(move(src, 2, 0)).toBe(src)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('from과 to가 같으면 원본을 그대로 반환한다', () => {
|
||||||
|
const src = [1, 2, 3]
|
||||||
|
expect(move(src, 1, 1)).toBe(src)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function move<T>(arr: T[], from: number, to: number): T[] {
|
||||||
|
if (
|
||||||
|
from === to ||
|
||||||
|
from < 0 ||
|
||||||
|
to < 0 ||
|
||||||
|
from >= arr.length ||
|
||||||
|
to >= arr.length
|
||||||
|
) {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
const next = arr.slice()
|
||||||
|
const [sp] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, sp)
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"app.title": "Landing Page Builder",
|
||||||
|
"builder.title": "Landing Page Builder",
|
||||||
|
"builder.preview": "Preview (static)",
|
||||||
|
"builder.field.title": "Title",
|
||||||
|
"builder.field.locale": "Locale",
|
||||||
|
"builder.field.primaryColor": "Primary Color",
|
||||||
|
"builder.field.fontFamily": "Font Family",
|
||||||
|
"builder.upload.hero": "Hero Image Upload",
|
||||||
|
"builder.action.addHero": "Add Hero",
|
||||||
|
"builder.action.addFAQ": "Add FAQ",
|
||||||
|
"builder.action.addCTA": "Add CTA",
|
||||||
|
"builder.action.export": "Export ZIP",
|
||||||
|
"builder.action.addText": "Add Text",
|
||||||
|
"builder.action.addEmail": "Add Email",
|
||||||
|
"builder.action.addTel": "Add Tel",
|
||||||
|
"builder.action.addDate": "Add Date",
|
||||||
|
"builder.action.addTextarea": "Add Textarea",
|
||||||
|
"builder.action.addCheckbox": "Add Checkbox",
|
||||||
|
"builder.action.addSelect": "Add Select",
|
||||||
|
"builder.action.addRadio": "Add Radio",
|
||||||
|
"builder.sections": "Sections",
|
||||||
|
"builder.form.fields": "Form Fields",
|
||||||
|
"builder.form.actionUrl": "Action URL",
|
||||||
|
"builder.form.honeypot": "Honeypot Field",
|
||||||
|
"builder.form.minSeconds": "Min Submit Seconds"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"app.title": "랜딩 페이지 빌더",
|
||||||
|
"builder.title": "랜딩 페이지 빌더",
|
||||||
|
"builder.preview": "미리보기(정적)",
|
||||||
|
"builder.field.title": "제목",
|
||||||
|
"builder.field.locale": "로케일",
|
||||||
|
"builder.field.primaryColor": "대표 색상",
|
||||||
|
"builder.field.fontFamily": "폰트",
|
||||||
|
"builder.upload.hero": "히어로 이미지 업로드",
|
||||||
|
"builder.action.addHero": "히어로 추가",
|
||||||
|
"builder.action.addFAQ": "FAQ 추가",
|
||||||
|
"builder.action.addCTA": "CTA 추가",
|
||||||
|
"builder.action.export": "ZIP 내보내기",
|
||||||
|
"builder.sections": "섹션",
|
||||||
|
"builder.form.fields": "폼 필드"
|
||||||
|
}
|
||||||
Generated
+3635
-6
File diff suppressed because it is too large
Load Diff
+30
-7
@@ -6,22 +6,45 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest -w",
|
||||||
|
"coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"next": "16.0.2",
|
||||||
|
"next-intl": "^4.5.2",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"next": "16.0.2"
|
"zod": "^4.1.12",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"@playwright/test": "^1.56.1",
|
||||||
"typescript": "^5",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/jest-axe": "^3.5.9",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@vitest/coverage-v8": "^4.0.8",
|
||||||
"tailwindcss": "^4",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.2"
|
"eslint-config-next": "16.0.2",
|
||||||
|
"jest-axe": "^10.0.0",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests/e2e',
|
||||||
|
testMatch: ['**/*.e2e.ts'],
|
||||||
|
timeout: 30_000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
headless: true,
|
||||||
|
acceptDownloads: true,
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:3000/en/builder',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { locales, defaultLocale } from '@/lib/i18n/locales'
|
||||||
|
|
||||||
|
function hasLocale(pathname: string) {
|
||||||
|
const first = pathname.split('/')[1]
|
||||||
|
return (locales as readonly string[]).includes(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Ignore next internals and assets
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.startsWith('/api') ||
|
||||||
|
pathname.includes('.') // static files
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add default locale if missing
|
||||||
|
if (!hasLocale(pathname)) {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = `/${defaultLocale}${pathname}`
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next|.*\..*).*)'],
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
test.describe('Builder basic flow', () => {
|
||||||
|
test('add sections, edit via inspector, preview updates', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
|
||||||
|
await expect(page.getByTestId('builder-shell')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByTestId('card-hero').click()
|
||||||
|
await page.getByTestId('card-faq').click()
|
||||||
|
await page.getByTestId('card-features').click()
|
||||||
|
await page.getByTestId('card-testimonials').click()
|
||||||
|
await page.getByTestId('card-cta').click()
|
||||||
|
|
||||||
|
// Hero edit
|
||||||
|
await page.getByLabel('Select 0').click()
|
||||||
|
await page.getByLabel('Hero Heading').fill('Hello World')
|
||||||
|
|
||||||
|
// FAQ edit
|
||||||
|
await page.getByLabel('Select 1').click()
|
||||||
|
await page.getByRole('button', { name: 'Add FAQ Item' }).click()
|
||||||
|
await page.getByLabel('FAQ Q 0').fill('What is this?')
|
||||||
|
await page.getByLabel('FAQ A 0').fill('A builder.')
|
||||||
|
|
||||||
|
// Features edit
|
||||||
|
await page.getByLabel('Select 2').click()
|
||||||
|
await page.getByRole('button', { name: 'Add Feature Item' }).click()
|
||||||
|
await page.getByRole('textbox', { name: 'Feature 0' }).fill('Fast setup')
|
||||||
|
|
||||||
|
// Testimonials edit
|
||||||
|
await page.getByLabel('Select 3').click()
|
||||||
|
await page.getByRole('button', { name: 'Add Testimonial' }).click()
|
||||||
|
await page.getByLabel('Author 0').fill('Alice')
|
||||||
|
await page.getByLabel('Quote 0').fill('Great!')
|
||||||
|
|
||||||
|
// CTA edit
|
||||||
|
await page.getByLabel('Select 4').click()
|
||||||
|
await page.getByLabel('CTA Text').fill('Start Now')
|
||||||
|
await page.getByLabel('CTA Href').fill('/start')
|
||||||
|
|
||||||
|
// Preview assertions
|
||||||
|
await expect(page.getByText('• hero — heading: Hello World')).toBeVisible()
|
||||||
|
await expect(page.getByText('• faq — items: 1')).toBeVisible()
|
||||||
|
await expect(page.getByText(/features — items:\s*4/)).toBeVisible()
|
||||||
|
await expect(page.getByText(/testimonials — items:\s*3/)).toBeVisible()
|
||||||
|
await expect(page.getByText('• cta — Start Now → /start')).toBeVisible()
|
||||||
|
|
||||||
|
// Export should not crash
|
||||||
|
await page.getByRole('button', { name: /Export/i }).click()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('e2e placeholder (see .e2e.ts files for Playwright)', () => {
|
||||||
|
it('skips in Vitest environment', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
test('dnd keyboard fallback: Up/Down buttons reorder sections', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
|
||||||
|
// add specific sections in order: hero, faq, cta
|
||||||
|
await page.getByTestId('card-hero').click()
|
||||||
|
await page.getByTestId('card-faq').click()
|
||||||
|
await page.getByTestId('card-cta').click()
|
||||||
|
|
||||||
|
const list = page.getByTestId('section-canvas')
|
||||||
|
const items = () => list.getByTestId(/sec-item-/)
|
||||||
|
|
||||||
|
// initial order
|
||||||
|
await expect(items().nth(0)).toContainText('hero')
|
||||||
|
await expect(items().nth(1)).toContainText('faq')
|
||||||
|
await expect(items().nth(2)).toContainText('cta')
|
||||||
|
|
||||||
|
// move first item down
|
||||||
|
await items().nth(0).getByLabel('Down 0').click()
|
||||||
|
await expect(items().nth(0)).toContainText('faq')
|
||||||
|
await expect(items().nth(1)).toContainText('hero')
|
||||||
|
await expect(items().nth(2)).toContainText('cta')
|
||||||
|
|
||||||
|
// move last item up
|
||||||
|
await items().nth(2).getByLabel('Up 2').click()
|
||||||
|
await expect(items().nth(0)).toContainText('faq')
|
||||||
|
await expect(items().nth(1)).toContainText('cta')
|
||||||
|
await expect(items().nth(2)).toContainText('hero')
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
type WindowWithExport = Window & {
|
||||||
|
__captureExport?: boolean
|
||||||
|
__lastExport?: { html: string; css: string; js: string }
|
||||||
|
__lastBlob?: Blob
|
||||||
|
__exportPreview?: () => { html: string; css: string; js: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Export ZIP contains expected SEO/OG/Analytics meta in index.html', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
// enable capture hook in app
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as WindowWithExport
|
||||||
|
w.__captureExport = true
|
||||||
|
w.__lastBlob = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// set SEO/Analytics/OG values
|
||||||
|
await page.getByLabel('SEO Title').fill('E2E SEO Title')
|
||||||
|
await page.getByLabel('SEO Description').fill('E2E SEO Description')
|
||||||
|
await page.getByLabel('OG Image URL').fill('https://example.com/e2e-og.png')
|
||||||
|
await page.getByLabel('GA4 Measurement ID').fill('G-E2E1234567')
|
||||||
|
await page.getByLabel('Meta Pixel ID').fill('9876543210')
|
||||||
|
|
||||||
|
// Prefer calling exporter directly for deterministic output
|
||||||
|
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
|
||||||
|
let indexHtml: string | null = exported?.html ?? null
|
||||||
|
|
||||||
|
// Fallback: also click Export to exercise button and capture via test hook
|
||||||
|
if (!indexHtml) {
|
||||||
|
await page.getByRole('button', { name: /Export/i }).click()
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const w = window as unknown as WindowWithExport
|
||||||
|
return Boolean(w.__lastExport || w.__lastBlob)
|
||||||
|
}, { timeout: 20000 })
|
||||||
|
const exp2 = await page.evaluate(() => (window as unknown as WindowWithExport).__lastExport ?? null)
|
||||||
|
indexHtml = exp2?.html ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// if not present, fall back to reading from ZIP blob (slower)
|
||||||
|
if (!indexHtml) {
|
||||||
|
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__lastBlob), { timeout: 20000 })
|
||||||
|
const base64 = await page.evaluate(async () => {
|
||||||
|
const blob: Blob | undefined = (window as unknown as WindowWithExport).__lastBlob
|
||||||
|
if (!blob) return null
|
||||||
|
const ab = await blob.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(ab)
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
})
|
||||||
|
expect(base64, 'export blob should be captured').not.toBeNull()
|
||||||
|
const buf = Buffer.from(base64 as string, 'base64')
|
||||||
|
const zip = await JSZip.loadAsync(buf)
|
||||||
|
const index = zip.file('index.html')
|
||||||
|
expect(index, 'index.html exists in zip').toBeTruthy()
|
||||||
|
indexHtml = await index!.async('string')
|
||||||
|
}
|
||||||
|
// assert we have HTML
|
||||||
|
expect(indexHtml).not.toBeNull()
|
||||||
|
|
||||||
|
// assertions: language, title/description, og tags, GA4 id presence
|
||||||
|
expect(indexHtml!).toContain('<html lang="en">')
|
||||||
|
expect(indexHtml!).toContain('<meta name="description" content="E2E SEO Description" />')
|
||||||
|
expect(indexHtml!).toContain('<meta property="og:title" content="E2E SEO Title" />')
|
||||||
|
expect(indexHtml!).toContain('<meta property="og:description" content="E2E SEO Description" />')
|
||||||
|
expect(indexHtml!).toContain('<meta property="og:image" content="https://example.com/e2e-og.png" />')
|
||||||
|
expect(indexHtml!).toContain('G-E2E1234567')
|
||||||
|
expect(indexHtml!).toContain('9876543210')
|
||||||
|
|
||||||
|
// accessibility scaffolding in exported HTML
|
||||||
|
expect(indexHtml!).toContain('<a href="#main" class="skip-link">')
|
||||||
|
expect(indexHtml!).toContain('<main id="main" role="main">')
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
type WindowWithExport = Window & {
|
||||||
|
__exportPreview?: () => { html: string; css: string; js: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Preview/export markers: heading/ARIA/responsive tokens exist', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
|
||||||
|
// add sections to ensure markers exist in export
|
||||||
|
await page.getByTestId('card-hero').click()
|
||||||
|
await page.getByTestId('card-features').click()
|
||||||
|
await page.getByTestId('card-testimonials').click()
|
||||||
|
await page.getByTestId('card-faq').click()
|
||||||
|
await page.getByTestId('card-cta').click()
|
||||||
|
|
||||||
|
// ensure FAQ has at least one item to satisfy schema
|
||||||
|
await page.getByLabel('Select 3').click()
|
||||||
|
await page.getByRole('button', { name: 'Add FAQ Item' }).click()
|
||||||
|
await page.getByLabel('FAQ Q 0').fill('What?')
|
||||||
|
await page.getByLabel('FAQ A 0').fill('Answer')
|
||||||
|
|
||||||
|
// ensure exporter hook is present
|
||||||
|
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
|
||||||
|
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
|
||||||
|
expect(exported).not.toBeNull()
|
||||||
|
|
||||||
|
const html = exported!.html
|
||||||
|
const css = exported!.css
|
||||||
|
|
||||||
|
// headings/aria
|
||||||
|
expect(html).toContain('<h1 id="hero-heading">')
|
||||||
|
expect(html).toContain('aria-labelledby="features-heading"')
|
||||||
|
expect(html).toContain('aria-labelledby="testimonials-heading"')
|
||||||
|
expect(html).toContain('aria-labelledby="faq-heading"')
|
||||||
|
expect(html).toContain('aria-labelledby="cta-heading"')
|
||||||
|
|
||||||
|
// responsive/a11y css tokens
|
||||||
|
expect(css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
expect(css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
test('SEO/Analytics inspector updates reflect in preview', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
|
||||||
|
await page.getByLabel('SEO Title').fill('My SEO Title')
|
||||||
|
await page.getByLabel('SEO Description').fill('My SEO Description')
|
||||||
|
await page.getByLabel('OG Image URL').fill('https://example.com/og.png')
|
||||||
|
await page.getByLabel('GA4 Measurement ID').fill('G-ABC1234567')
|
||||||
|
await page.getByLabel('Meta Pixel ID').fill('1234567890')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('preview-seo-title')).toHaveText('My SEO Title')
|
||||||
|
await expect(page.getByTestId('preview-seo-description')).toHaveText('My SEO Description')
|
||||||
|
await expect(page.getByTestId('preview-og-image')).toHaveText('https://example.com/og.png')
|
||||||
|
await expect(page.getByTestId('preview-ga4')).toHaveText('G-ABC1234567')
|
||||||
|
await expect(page.getByTestId('preview-meta-pixel')).toHaveText('1234567890')
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
test('Preview viewport toggle sets aria-pressed and container width', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
|
||||||
|
const desktopBtn = page.getByRole('button', { name: 'Desktop' })
|
||||||
|
const mobileBtn = page.getByRole('button', { name: 'Mobile' })
|
||||||
|
const container = page.getByTestId('preview-container')
|
||||||
|
|
||||||
|
// default: desktop
|
||||||
|
await expect(desktopBtn).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
await expect(mobileBtn).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
await expect(container).toHaveClass(/w-\[768px\]/)
|
||||||
|
|
||||||
|
// switch to mobile
|
||||||
|
await mobileBtn.click()
|
||||||
|
await expect(desktopBtn).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
await expect(mobileBtn).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
await expect(container).toHaveClass(/w-\[375px\]/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const builderUrl = '/en/builder'
|
||||||
|
|
||||||
|
test('Preview viewport toggle via keyboard (Tab + Enter/Space)', async ({ page }) => {
|
||||||
|
await page.goto(builderUrl)
|
||||||
|
|
||||||
|
// Tab to first button (Desktop)
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await expect(page.getByRole('button', { name: 'Desktop' })).toBeFocused()
|
||||||
|
|
||||||
|
// Press Space to toggle Mobile (first ensure Desktop is selected, then move to Mobile with Tab)
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await expect(page.getByRole('button', { name: 'Mobile' })).toBeFocused()
|
||||||
|
await page.keyboard.press('Space')
|
||||||
|
await expect(page.getByRole('button', { name: 'Mobile' })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
|
||||||
|
// Back to Desktop with Shift+Tab and Enter
|
||||||
|
await page.keyboard.down('Shift')
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await page.keyboard.up('Shift')
|
||||||
|
await expect(page.getByRole('button', { name: 'Desktop' })).toBeFocused()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.getByRole('button', { name: 'Desktop' })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./setupTests.ts'],
|
||||||
|
globals: true,
|
||||||
|
css: false,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reportsDirectory: './coverage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user