"use client" import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useTranslations } from 'next-intl' import { pageSchema, type Page } from '@/lib/schema/page' import { exportPage } from '@/lib/exporter/html' import { AssetManager } from '@/lib/assets/assetManager' import { buildZip } from '@/lib/exporter/zip' import type { Section } from '@/lib/state/store' import { createFormStore, type FormState } from '@/lib/state/formStore' import FormBuilderPanel from '@/components/FormBuilderPanel' import BuilderShell from '@/components/BuilderShell' import SectionCanvas from '@/components/SectionCanvas' import SidebarComponents from '@/components/SidebarComponents' function download(filename: string, data: Uint8Array) { const ab = data.slice().buffer as ArrayBuffer const blob = new Blob([ab], { type: 'application/zip' }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastBlob?: Blob } if (w.__captureExport) { w.__lastBlob = blob } } } catch {} const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } export default function BuilderClientPage() { const t = useTranslations() const [locale, setLocale] = useState<'en' | 'ko'>('en') const [title, setTitle] = useState('My Landing') const [primaryColor, setPrimaryColor] = useState('#0ea5e9') const [fontFamily, setFontFamily] = useState('Inter') const [sections, setSections] = useState([]) const [selectedIndex, setSelectedIndex] = useState(null) const [seoTitle, setSeoTitle] = useState('') const [seoDescription, setSeoDescription] = useState('') const [ogImage, setOgImage] = useState('') const [ga4MeasurementId, setGa4MeasurementId] = useState('') const [metaPixelId, setMetaPixelId] = useState('') const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => { if (typeof window !== 'undefined') { try { const saved = localStorage.getItem('builder.viewport') as 'desktop' | 'mobile' | null if (saved === 'desktop' || saved === 'mobile') return saved } catch {} } return 'desktop' }) const fileInputRef = useRef(null) const assetManagerRef = useRef( new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'] }) ) useEffect(() => { try { localStorage.setItem('builder.viewport', viewport) } catch {} }, [viewport]) const [am, setAm] = useState(null) useEffect(() => { setAm(assetManagerRef.current) }, []) const [selectedFileName, setSelectedFileName] = useState('') // form store (stable instance without ref access in render) const formStore = useMemo(() => createFormStore(), []) const formState: FormState = useSyncExternalStore( formStore.subscribe, formStore.getState, formStore.getState ) const addHero = useCallback(async () => { const fileEl = fileInputRef.current if (fileEl && fileEl.files && fileEl.files.length > 0) { const file = fileEl.files[0] const buf = new Uint8Array(await file.arrayBuffer()) if (am) { await am.add({ name: file.name, type: file.type, bytes: buf }) } setSelectedFileName(file.name) } // 파일이 없어도 Hero 섹션은 추가(이미지는 placeholder 사용) const hero: Section = { type: 'hero', props: { heading: 'Welcome', subheading: '' } } setSections((prev) => [...prev, hero]) }, [am]) const addFaq = useCallback(() => { const faq: Section = { type: 'faq', props: { items: [] } } setSections((prev) => [...prev, faq]) }, []) const addCta = useCallback(() => { const cta: Section = { type: 'cta', props: { text: 'Get Started', href: '#' } } setSections((prev) => [...prev, cta]) }, []) const addFeatures = useCallback(() => { const features: Section = { type: 'features', props: { items: ['Feature A', 'Feature B', 'Feature C'] } } setSections((prev) => [...prev, features]) }, []) const addTestimonials = useCallback(() => { const testimonials: Section = { type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great product!' }, { author: 'John', quote: 'Loved it.' }] }, } setSections((prev) => [...prev, testimonials]) }, []) const removeAt = useCallback((idx: number) => { setSections((prev) => prev.filter((_, i) => i !== idx)) }, []) const moveSection = useCallback((from: number, to: number) => { setSections((prev) => { if (from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev const next = prev.slice() const [sp] = next.splice(from, 1) next.splice(to, 0, sp) return next }) }, []) const pageData = useMemo(() => { const firstName = selectedFileName const mappedSections = sections.map((s: Section) => { if (s.type === 'hero') { return { type: 'hero' as const, props: { heading: s.props?.heading || 'Welcome', subheading: s.props?.subheading || '', imageUrl: firstName ? `local:${firstName}` : 'https://via.placeholder.com/800x400', }, } } return s }) return ({ title, locale, theme: { primaryColor, fontFamily }, sections: (mappedSections as unknown) as Page['sections'], form: formState, analytics: { ga4MeasurementId, metaPixelId, customHeadHtml: '' }, seo: { title: seoTitle || title, description: (seoDescription && seoDescription.trim()) ? seoDescription : 'Landing page', ogImage: (ogImage && ogImage.trim()) ? ogImage : undefined }, } as unknown) as Page }, [title, locale, primaryColor, fontFamily, sections, formState, selectedFileName, seoTitle, seoDescription, ga4MeasurementId, metaPixelId, ogImage]) // test-only: expose preview exporter for E2E to read exported HTML/CSS/JS directly useEffect(() => { try { if (typeof window !== 'undefined') { const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } } w.__exportPreview = () => { try { return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined }) } catch { const fixed: Page = { ...pageData, form: { actionUrl: pageData.form?.actionUrl ?? '', fields: pageData.form?.fields ?? [], spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, }, } return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined }) } } } } catch {} }, [pageData, am]) const doExport = useCallback(async () => { const valid = pageSchema.parse(pageData) const exported = exportPage(valid, { assetManager: am ?? undefined }) try { if (typeof window !== 'undefined') { const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } } if (w.__captureExport) { w.__lastExport = exported } } } catch {} const assets = am ? am.toZipStructure() : {} const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets }) download('landing.zip', zipBytes) }, [pageData, am]) return (

{t('builder.title')}

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

SEO / Analytics

Inspector

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

Main heading displayed prominently

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

Button label and link for primary action

Button label and link for primary action

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

Add at least one FAQ item with Q and A

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

Describe a single feature per line

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

Add author and quote for each testimonial

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

{t('builder.preview')}

Toggle preview viewport for the builder preview area

Title: {pageData.title}
Locale: {pageData.locale}
SEO Title: {pageData.seo.title}
SEO Description: {pageData.seo.description}
OG Image: {pageData.seo.ogImage || ''}
GA4: {pageData.analytics?.ga4MeasurementId || ''}
Meta Pixel: {pageData.analytics?.metaPixelId || ''}
{sections.map((s: Section, i: number) => { switch (s.type) { case 'hero': return (
• hero — heading: {s.props?.heading || 'Welcome'}
) case 'faq': { const count = s.props.items?.length || 0 return (
• faq — items: {count}
) } case 'cta': { const text = s.props.text || 'CTA' const href = s.props.href || '#' return (
• cta — {text} → {href}
) } case 'features': { const count = s.props.items?.length || 0 return (
• features — items: {count}
) } case 'testimonials': { const count = s.props.items?.length || 0 return (
• testimonials — items: {count}
) } default: return null } })}
    {formState.fields.map((f, idx) => (
  • {f.label} ({f.name}) {f.required ? 'required' : ''}
  • ))}
} /> ) }