test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user