@siteable/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +4 -0
- package/README.md +82 -0
- package/dist/index.d.ts +401 -0
- package/dist/index.js +5389 -0
- package/package.json +66 -0
- package/src/blocks/BlockWrapper.tsx +149 -0
- package/src/blocks/banner/BannerBlock.tsx +38 -0
- package/src/blocks/contact/ContactBlock.tsx +63 -0
- package/src/blocks/content/ContentBlock.tsx +38 -0
- package/src/blocks/cta/CtaBlock.tsx +67 -0
- package/src/blocks/divider/DividerBlock.tsx +30 -0
- package/src/blocks/faq/FaqBlock.tsx +82 -0
- package/src/blocks/features/FeaturesBlock.tsx +170 -0
- package/src/blocks/footer/FooterBlock.tsx +136 -0
- package/src/blocks/gallery/GalleryBlock.tsx +67 -0
- package/src/blocks/hero/HeroBlock.tsx +163 -0
- package/src/blocks/image/ImageBlock.tsx +78 -0
- package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
- package/src/blocks/navbar/NavbarBlock.tsx +102 -0
- package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
- package/src/blocks/pricing/PricingBlock.tsx +173 -0
- package/src/blocks/registry.tsx +90 -0
- package/src/blocks/stats/StatsBlock.tsx +96 -0
- package/src/blocks/team/TeamBlock.tsx +50 -0
- package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
- package/src/blocks/types.ts +72 -0
- package/src/blocks/video/VideoBlock.tsx +58 -0
- package/src/editor/AgentPanel.tsx +318 -0
- package/src/editor/Canvas.tsx +95 -0
- package/src/editor/CanvasEmpty.tsx +40 -0
- package/src/editor/CanvasToolbar.tsx +366 -0
- package/src/editor/DesignPanel.tsx +224 -0
- package/src/editor/EditorLayout.tsx +196 -0
- package/src/editor/GenerationOverlay.tsx +201 -0
- package/src/editor/JsonDrawer.tsx +139 -0
- package/src/editor/LayersPanel.tsx +259 -0
- package/src/editor/LeftSidebar.tsx +136 -0
- package/src/editor/PropertiesPanel.tsx +564 -0
- package/src/editor/RightSidebar.tsx +85 -0
- package/src/editor/ShortcutsModal.tsx +126 -0
- package/src/editor/VersionHistory.tsx +110 -0
- package/src/index.ts +133 -0
- package/src/lib/block-metadata.ts +167 -0
- package/src/lib/export-html.ts +1424 -0
- package/src/lib/generate-site.ts +206 -0
- package/src/lib/generation-prompt.ts +64 -0
- package/src/lib/markdown.ts +88 -0
- package/src/lib/templates.ts +139 -0
- package/src/lib/theme-presets.ts +330 -0
- package/src/lib/useGoogleFonts.ts +49 -0
- package/src/lib/useScrollReveal.ts +28 -0
- package/src/settings/GeminiKeyInputField.tsx +82 -0
- package/src/store/configStore.ts +344 -0
- package/src/store/editorStore.ts +50 -0
- package/src/styles.css +210 -0
|
@@ -0,0 +1,1424 @@
|
|
|
1
|
+
import type { SiteConfig, BlockConfig } from '@/blocks/types'
|
|
2
|
+
import { resolveTheme } from '@/lib/theme-presets'
|
|
3
|
+
|
|
4
|
+
export interface ExportSiteSettings {
|
|
5
|
+
siteName?: string
|
|
6
|
+
siteDescription?: string
|
|
7
|
+
faviconUrl?: string
|
|
8
|
+
language?: string
|
|
9
|
+
seoTitle?: string
|
|
10
|
+
seoDescription?: string
|
|
11
|
+
ogImageUrl?: string
|
|
12
|
+
gaId?: string
|
|
13
|
+
posthogKey?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExportSiteOptions {
|
|
17
|
+
settings?: ExportSiteSettings
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// AI-generated props can deviate from the schema (objects where strings
|
|
25
|
+
// are expected, numbers, nulls). Coerce instead of crashing the export.
|
|
26
|
+
function escapeHtml(value: unknown): string {
|
|
27
|
+
return String(value ?? '')
|
|
28
|
+
.replace(/&/g, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, ''')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderLink(text: string, url?: string, className?: string): string {
|
|
36
|
+
const cls = className ? ` class="${escapeHtml(className)}"` : ''
|
|
37
|
+
const escaped = escapeHtml(text)
|
|
38
|
+
if (url && url.trim()) {
|
|
39
|
+
return `<a href="${escapeHtml(url)}"${cls}>${escaped}</a>`
|
|
40
|
+
}
|
|
41
|
+
return `<span${cls}>${escaped}</span>`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function googleFontUrl(fonts: string[]): string {
|
|
45
|
+
const unique = [...new Set(fonts.filter(Boolean))]
|
|
46
|
+
const families = unique.map(
|
|
47
|
+
(f) => `family=${f.replace(/ /g, '+')}:wght@300;400;500;600;700`
|
|
48
|
+
)
|
|
49
|
+
return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function initials(name: string): string {
|
|
53
|
+
return escapeHtml(
|
|
54
|
+
name
|
|
55
|
+
.split(' ')
|
|
56
|
+
.map((n) => n[0])
|
|
57
|
+
.join('')
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function prop<T>(props: Record<string, unknown>, key: string, fallback: T): T {
|
|
62
|
+
const val = props[key]
|
|
63
|
+
if (val === undefined || val === null) return fallback
|
|
64
|
+
return val as T
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeLanguage(value?: string): string {
|
|
68
|
+
const normalized = (value || '').toLowerCase().trim()
|
|
69
|
+
if (!normalized) return 'en'
|
|
70
|
+
if (normalized === 'english' || normalized.startsWith('en')) return 'en'
|
|
71
|
+
if (normalized === 'german' || normalized.startsWith('de')) return 'de'
|
|
72
|
+
if (normalized === 'spanish' || normalized.startsWith('es')) return 'es'
|
|
73
|
+
if (normalized === 'french' || normalized.startsWith('fr')) return 'fr'
|
|
74
|
+
return 'en'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// SVG icons (inline, no dependencies)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const SVG_CHECK =
|
|
82
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
|
|
83
|
+
|
|
84
|
+
const SVG_CHEVRON_DOWN =
|
|
85
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>'
|
|
86
|
+
|
|
87
|
+
const SVG_MENU =
|
|
88
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>'
|
|
89
|
+
|
|
90
|
+
const SVG_SPARKLES =
|
|
91
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/></svg>'
|
|
92
|
+
|
|
93
|
+
const SVG_STAR_FILLED =
|
|
94
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
|
95
|
+
|
|
96
|
+
const SVG_STAR_EMPTY =
|
|
97
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
|
98
|
+
|
|
99
|
+
const SVG_QUOTE =
|
|
100
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3"/></svg>'
|
|
101
|
+
|
|
102
|
+
const SVG_SEND =
|
|
103
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>'
|
|
104
|
+
|
|
105
|
+
const SVG_MAIL =
|
|
106
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>'
|
|
107
|
+
|
|
108
|
+
const SVG_STAR_10 =
|
|
109
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
|
110
|
+
|
|
111
|
+
/** Simple icon placeholder for feature items (rounded square with initial) */
|
|
112
|
+
function featureIconSvg(iconName: string): string {
|
|
113
|
+
const char = escapeHtml(iconName.charAt(0).toUpperCase())
|
|
114
|
+
return `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" stroke="currentColor" stroke-width="1.5"/><text x="9" y="13" text-anchor="middle" fill="currentColor" font-size="10" font-weight="600" font-family="system-ui, sans-serif">${char}</text></svg>`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function starRatingHtml(rating: number): string {
|
|
118
|
+
const stars = [1, 2, 3, 4, 5]
|
|
119
|
+
.map((i) =>
|
|
120
|
+
i <= rating
|
|
121
|
+
? `<span class="text-yellow-400">${SVG_STAR_FILLED}</span>`
|
|
122
|
+
: `<span class="text-text-3">${SVG_STAR_EMPTY}</span>`
|
|
123
|
+
)
|
|
124
|
+
.join('')
|
|
125
|
+
return `<div class="flex gap-0.5">${stars}</div>`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function logoPlaceholderSvg(name: string): string {
|
|
129
|
+
const char = escapeHtml(name.charAt(0).toUpperCase())
|
|
130
|
+
return `<svg width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="26" height="26" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="14" y="18" text-anchor="middle" fill="currentColor" font-size="14" font-weight="700" font-family="system-ui, sans-serif">${char}</text></svg>`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Block renderers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function renderNavbar(block: BlockConfig): string {
|
|
138
|
+
const logo = escapeHtml(prop(block.props, 'logo', 'Brand'))
|
|
139
|
+
const logoImage = prop<string>(block.props, 'logoImage', '')
|
|
140
|
+
const links = prop<string[]>(block.props, 'links', [])
|
|
141
|
+
const ctaText = escapeHtml(prop(block.props, 'ctaText', 'Get Started'))
|
|
142
|
+
|
|
143
|
+
const navLinks = links
|
|
144
|
+
.map((l) => {
|
|
145
|
+
// Gemini sometimes returns link objects ({label|text, url}) instead of plain strings
|
|
146
|
+
const label = typeof l === 'string' ? l : ((l as { label?: string; text?: string })?.label ?? (l as { text?: string })?.text ?? '')
|
|
147
|
+
return label
|
|
148
|
+
})
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.map(
|
|
151
|
+
(l) =>
|
|
152
|
+
` <span class="text-[13px] text-text-2 hover:text-text-0 transition-colors cursor-pointer">${escapeHtml(l)}</span>`
|
|
153
|
+
)
|
|
154
|
+
.join('\n')
|
|
155
|
+
|
|
156
|
+
const logoHtml = logoImage
|
|
157
|
+
? `<img src="${escapeHtml(logoImage)}" alt="${logo}" class="h-8 w-auto object-contain" />`
|
|
158
|
+
: `<div class="w-8 h-8 rounded-lg bg-green/10 flex items-center justify-center"><div class="w-4 h-4 rounded-full bg-green"></div></div>`
|
|
159
|
+
|
|
160
|
+
return ` <nav class="px-6 md:px-10 py-4 flex items-center justify-between">
|
|
161
|
+
<div class="flex items-center gap-2">
|
|
162
|
+
${logoHtml}
|
|
163
|
+
<span class="font-semibold text-[15px] text-text-0 tracking-tight">${logo}</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="hidden lg:flex items-center gap-6">
|
|
166
|
+
${navLinks}
|
|
167
|
+
</div>
|
|
168
|
+
<div class="flex items-center gap-3">
|
|
169
|
+
<button class="px-4 py-2 rounded-lg bg-green text-black text-[13px] font-semibold hover:bg-green-dim transition-colors">${ctaText}</button>
|
|
170
|
+
<button class="lg:hidden w-9 h-9 rounded-lg border border-border-default flex items-center justify-center text-text-2 hover:text-text-0 hover:bg-bg-3 transition-colors">
|
|
171
|
+
${SVG_MENU}
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
</nav>`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Hero variants
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function renderHeroCentered(block: BlockConfig): string {
|
|
182
|
+
const badge = prop<string>(block.props, 'badge', '')
|
|
183
|
+
const headline = escapeHtml(prop(block.props, 'headline', ''))
|
|
184
|
+
const subheadline = escapeHtml(prop(block.props, 'subheadline', ''))
|
|
185
|
+
const primaryCta = prop<string>(block.props, 'primaryCta', '')
|
|
186
|
+
const primaryCtaUrl = prop<string>(block.props, 'primaryCtaUrl', '')
|
|
187
|
+
const secondaryCta = prop<string>(block.props, 'secondaryCta', '')
|
|
188
|
+
const secondaryCtaUrl = prop<string>(block.props, 'secondaryCtaUrl', '')
|
|
189
|
+
|
|
190
|
+
const badgeHtml = badge
|
|
191
|
+
? ` <div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-green/10 border border-green/20 text-green text-[11px] font-medium mb-6">
|
|
192
|
+
${SVG_SPARKLES}
|
|
193
|
+
${escapeHtml(badge)}
|
|
194
|
+
</div>`
|
|
195
|
+
: ''
|
|
196
|
+
|
|
197
|
+
const secondaryHtml = secondaryCta
|
|
198
|
+
? ` ${renderLink(secondaryCta, secondaryCtaUrl, 'px-6 py-3 rounded-lg bg-bg-3 text-text-0 text-sm font-medium border border-border-default hover:bg-bg-4 hover:border-border-hover transition-all inline-block')}`
|
|
199
|
+
: ''
|
|
200
|
+
|
|
201
|
+
return ` <section class="px-6 md:px-10 py-20 md:py-28 text-center">
|
|
202
|
+
${badgeHtml}
|
|
203
|
+
<h1 class="text-4xl md:text-5xl font-bold tracking-tight leading-[1.1] mb-4 max-w-3xl mx-auto">${headline}</h1>
|
|
204
|
+
<p class="text-text-2 text-base md:text-lg leading-relaxed max-w-xl mx-auto mb-8">${subheadline}</p>
|
|
205
|
+
<div class="flex flex-wrap items-center justify-center gap-3">
|
|
206
|
+
${renderLink(primaryCta, primaryCtaUrl, 'px-6 py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all inline-flex items-center gap-2')}
|
|
207
|
+
${secondaryHtml}
|
|
208
|
+
</div>
|
|
209
|
+
</section>`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function renderHeroSplit(block: BlockConfig): string {
|
|
213
|
+
const badge = prop<string>(block.props, 'badge', '')
|
|
214
|
+
const headline = escapeHtml(prop(block.props, 'headline', ''))
|
|
215
|
+
const subheadline = escapeHtml(prop(block.props, 'subheadline', ''))
|
|
216
|
+
const primaryCta = prop<string>(block.props, 'primaryCta', '')
|
|
217
|
+
const primaryCtaUrl = prop<string>(block.props, 'primaryCtaUrl', '')
|
|
218
|
+
const secondaryCta = prop<string>(block.props, 'secondaryCta', '')
|
|
219
|
+
const secondaryCtaUrl = prop<string>(block.props, 'secondaryCtaUrl', '')
|
|
220
|
+
const heroImage = prop<string>(block.props, 'heroImage', '')
|
|
221
|
+
|
|
222
|
+
const badgeHtml = badge
|
|
223
|
+
? ` <div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-green/10 border border-green/20 text-green text-[11px] font-medium mb-4">
|
|
224
|
+
${SVG_SPARKLES}
|
|
225
|
+
${escapeHtml(badge)}
|
|
226
|
+
</div>`
|
|
227
|
+
: ''
|
|
228
|
+
|
|
229
|
+
const secondaryHtml = secondaryCta
|
|
230
|
+
? ` ${renderLink(secondaryCta, secondaryCtaUrl, 'px-6 py-3 rounded-lg bg-bg-3 text-text-0 text-sm font-medium border border-border-default hover:bg-bg-4 transition-all inline-block')}`
|
|
231
|
+
: ''
|
|
232
|
+
|
|
233
|
+
const imageHtml = heroImage
|
|
234
|
+
? ` <img src="${escapeHtml(heroImage)}" alt="" class="absolute inset-0 w-full h-full object-cover" />`
|
|
235
|
+
: ` <div class="absolute inset-0 bg-gradient-to-br from-green/5 to-transparent"></div>
|
|
236
|
+
<div class="absolute inset-6 border border-dashed border-border-default rounded-lg flex items-center justify-center text-text-3 text-sm">Preview</div>`
|
|
237
|
+
|
|
238
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-24 flex flex-col lg:flex-row items-center gap-10">
|
|
239
|
+
<div class="flex-1">
|
|
240
|
+
${badgeHtml}
|
|
241
|
+
<h1 class="text-3xl md:text-5xl font-bold tracking-tight leading-[1.1] mb-4">${headline}</h1>
|
|
242
|
+
<p class="text-text-2 text-base leading-relaxed mb-6 max-w-lg">${subheadline}</p>
|
|
243
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
244
|
+
${renderLink(primaryCta, primaryCtaUrl, 'px-6 py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all inline-flex items-center gap-2')}
|
|
245
|
+
${secondaryHtml}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="flex-1 w-full">
|
|
249
|
+
<div class="aspect-[4/3] rounded-xl bg-bg-2 border border-border-default overflow-hidden relative">
|
|
250
|
+
${imageHtml}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</section>`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderHeroGradient(block: BlockConfig): string {
|
|
257
|
+
const badge = prop<string>(block.props, 'badge', '')
|
|
258
|
+
const headline = escapeHtml(prop(block.props, 'headline', ''))
|
|
259
|
+
const subheadline = escapeHtml(prop(block.props, 'subheadline', ''))
|
|
260
|
+
const primaryCta = prop<string>(block.props, 'primaryCta', '')
|
|
261
|
+
const primaryCtaUrl = prop<string>(block.props, 'primaryCtaUrl', '')
|
|
262
|
+
const secondaryCta = prop<string>(block.props, 'secondaryCta', '')
|
|
263
|
+
const secondaryCtaUrl = prop<string>(block.props, 'secondaryCtaUrl', '')
|
|
264
|
+
|
|
265
|
+
const badgeHtml = badge
|
|
266
|
+
? ` <div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-green/10 border border-green/20 text-green text-[11px] font-medium mb-6">
|
|
267
|
+
${SVG_SPARKLES}
|
|
268
|
+
${escapeHtml(badge)}
|
|
269
|
+
</div>`
|
|
270
|
+
: ''
|
|
271
|
+
|
|
272
|
+
const secondaryHtml = secondaryCta
|
|
273
|
+
? ` ${renderLink(secondaryCta, secondaryCtaUrl, 'px-6 py-3 rounded-lg bg-bg-3 text-text-0 text-sm font-medium border border-border-default hover:bg-bg-4 transition-all inline-block')}`
|
|
274
|
+
: ''
|
|
275
|
+
|
|
276
|
+
return ` <section class="px-6 md:px-10 py-20 md:py-32 text-center relative overflow-hidden">
|
|
277
|
+
<div class="absolute inset-0 bg-gradient-to-b from-green/5 via-transparent to-transparent pointer-events-none"></div>
|
|
278
|
+
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-green/[0.08] rounded-full blur-[100px] pointer-events-none"></div>
|
|
279
|
+
<div class="relative z-10">
|
|
280
|
+
${badgeHtml}
|
|
281
|
+
<h1 class="text-4xl md:text-5xl font-bold tracking-tight leading-[1.1] mb-4 max-w-3xl mx-auto text-text-0">${headline}</h1>
|
|
282
|
+
<p class="text-text-2 text-base md:text-lg leading-relaxed max-w-xl mx-auto mb-8">${subheadline}</p>
|
|
283
|
+
<div class="flex flex-wrap items-center justify-center gap-3">
|
|
284
|
+
${renderLink(primaryCta, primaryCtaUrl, 'px-6 py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all inline-flex items-center gap-2')}
|
|
285
|
+
${secondaryHtml}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</section>`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderHero(block: BlockConfig): string {
|
|
292
|
+
switch (block.variant) {
|
|
293
|
+
case 'split':
|
|
294
|
+
return renderHeroSplit(block)
|
|
295
|
+
case 'gradient':
|
|
296
|
+
return renderHeroGradient(block)
|
|
297
|
+
default:
|
|
298
|
+
return renderHeroCentered(block)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Features
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
function renderFeaturesGrid(block: BlockConfig): string {
|
|
307
|
+
const label = prop<string>(block.props, 'label', '')
|
|
308
|
+
const title = escapeHtml(prop(block.props, 'title', ''))
|
|
309
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
310
|
+
const items = prop<{ icon?: string; title: string; description: string }[]>(
|
|
311
|
+
block.props,
|
|
312
|
+
'items',
|
|
313
|
+
[]
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
const labelHtml = label
|
|
317
|
+
? ` <div class="text-[11px] font-semibold uppercase tracking-widest text-green mb-2">${escapeHtml(label)}</div>`
|
|
318
|
+
: ''
|
|
319
|
+
const subtitleHtml = subtitle
|
|
320
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
321
|
+
: ''
|
|
322
|
+
|
|
323
|
+
const cards = items
|
|
324
|
+
.map(
|
|
325
|
+
(item) => ` <div class="group bg-bg-2 border border-border-default rounded-xl p-5 transition-all hover:-translate-y-0.5 hover:border-border-hover hover:shadow-[0_8px_24px_rgba(0,0,0,0.2)]">
|
|
326
|
+
<div class="w-10 h-10 rounded-lg bg-green/10 border border-green/20 flex items-center justify-center text-green mb-3">
|
|
327
|
+
${featureIconSvg(item.icon || 'Z')}
|
|
328
|
+
</div>
|
|
329
|
+
<h3 class="text-sm font-semibold mb-1">${escapeHtml(item.title)}</h3>
|
|
330
|
+
<p class="text-[12.5px] text-text-2 leading-relaxed">${escapeHtml(item.description)}</p>
|
|
331
|
+
</div>`
|
|
332
|
+
)
|
|
333
|
+
.join('\n')
|
|
334
|
+
|
|
335
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
336
|
+
<div class="text-center mb-10">
|
|
337
|
+
${labelHtml}
|
|
338
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
339
|
+
${subtitleHtml}
|
|
340
|
+
</div>
|
|
341
|
+
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
342
|
+
${cards}
|
|
343
|
+
</div>
|
|
344
|
+
</section>`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function renderFeaturesList(block: BlockConfig): string {
|
|
348
|
+
const label = prop<string>(block.props, 'label', '')
|
|
349
|
+
const title = escapeHtml(prop(block.props, 'title', ''))
|
|
350
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
351
|
+
const items = prop<{ icon?: string; title: string; description: string }[]>(
|
|
352
|
+
block.props,
|
|
353
|
+
'items',
|
|
354
|
+
[]
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const labelHtml = label
|
|
358
|
+
? ` <div class="text-[11px] font-semibold uppercase tracking-widest text-green mb-2">${escapeHtml(label)}</div>`
|
|
359
|
+
: ''
|
|
360
|
+
const subtitleHtml = subtitle
|
|
361
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
362
|
+
: ''
|
|
363
|
+
|
|
364
|
+
const rows = items
|
|
365
|
+
.map(
|
|
366
|
+
(item) => ` <div class="flex gap-4 p-4 rounded-xl bg-bg-2 border border-border-default transition-all hover:border-border-hover">
|
|
367
|
+
<div class="w-10 h-10 rounded-lg bg-green/10 border border-green/20 flex items-center justify-center text-green shrink-0">
|
|
368
|
+
${featureIconSvg(item.icon || 'Z')}
|
|
369
|
+
</div>
|
|
370
|
+
<div>
|
|
371
|
+
<h3 class="text-sm font-semibold mb-0.5">${escapeHtml(item.title)}</h3>
|
|
372
|
+
<p class="text-[12.5px] text-text-2 leading-relaxed">${escapeHtml(item.description)}</p>
|
|
373
|
+
</div>
|
|
374
|
+
</div>`
|
|
375
|
+
)
|
|
376
|
+
.join('\n')
|
|
377
|
+
|
|
378
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
379
|
+
<div class="text-center mb-10">
|
|
380
|
+
${labelHtml}
|
|
381
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
382
|
+
${subtitleHtml}
|
|
383
|
+
</div>
|
|
384
|
+
<div class="max-w-2xl mx-auto space-y-4">
|
|
385
|
+
${rows}
|
|
386
|
+
</div>
|
|
387
|
+
</section>`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function renderFeatures(block: BlockConfig): string {
|
|
391
|
+
switch (block.variant) {
|
|
392
|
+
case 'list':
|
|
393
|
+
return renderFeaturesList(block)
|
|
394
|
+
default:
|
|
395
|
+
return renderFeaturesGrid(block)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Pricing
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
interface PricingTier {
|
|
404
|
+
name: string
|
|
405
|
+
price: string
|
|
406
|
+
period?: string
|
|
407
|
+
description?: string
|
|
408
|
+
features: string[]
|
|
409
|
+
cta: string
|
|
410
|
+
featured?: boolean
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const defaultTiers: PricingTier[] = [
|
|
414
|
+
{
|
|
415
|
+
name: 'Starter',
|
|
416
|
+
price: '$0',
|
|
417
|
+
period: '/month',
|
|
418
|
+
description: 'For personal projects',
|
|
419
|
+
features: ['1 website', '5 blocks', 'Basic export', 'Community support'],
|
|
420
|
+
cta: 'Get Started',
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
name: 'Pro',
|
|
424
|
+
price: '$19',
|
|
425
|
+
period: '/month',
|
|
426
|
+
description: 'For professionals',
|
|
427
|
+
features: [
|
|
428
|
+
'Unlimited websites',
|
|
429
|
+
'All blocks',
|
|
430
|
+
'Custom domains',
|
|
431
|
+
'Priority support',
|
|
432
|
+
'Agent API access',
|
|
433
|
+
'Version history',
|
|
434
|
+
],
|
|
435
|
+
cta: 'Upgrade to Pro',
|
|
436
|
+
featured: true,
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: 'Team',
|
|
440
|
+
price: '$49',
|
|
441
|
+
period: '/month',
|
|
442
|
+
description: 'For teams and agencies',
|
|
443
|
+
features: [
|
|
444
|
+
'Everything in Pro',
|
|
445
|
+
'Team collaboration',
|
|
446
|
+
'Custom components',
|
|
447
|
+
'SSO',
|
|
448
|
+
'Dedicated support',
|
|
449
|
+
],
|
|
450
|
+
cta: 'Contact Sales',
|
|
451
|
+
},
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
function renderPricingSimple(block: BlockConfig): string {
|
|
455
|
+
const title = escapeHtml(prop(block.props, 'title', ''))
|
|
456
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
457
|
+
const tiers = prop<PricingTier[]>(block.props, 'tiers', defaultTiers)
|
|
458
|
+
|
|
459
|
+
const subtitleHtml = subtitle
|
|
460
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
461
|
+
: ''
|
|
462
|
+
|
|
463
|
+
const cards = tiers
|
|
464
|
+
.map((tier) => {
|
|
465
|
+
const featuredBorder = tier.featured
|
|
466
|
+
? 'bg-bg-2 border-2 border-green'
|
|
467
|
+
: 'bg-bg-2 border border-border-default hover:border-border-hover'
|
|
468
|
+
const featuredBadge = tier.featured
|
|
469
|
+
? ` <div class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full bg-green text-black text-[10px] font-bold uppercase tracking-wider flex items-center gap-1">
|
|
470
|
+
${SVG_STAR_10}
|
|
471
|
+
Recommended
|
|
472
|
+
</div>`
|
|
473
|
+
: ''
|
|
474
|
+
const btnClass = tier.featured
|
|
475
|
+
? 'w-full py-2.5 rounded-lg text-sm font-semibold transition-all bg-green text-black hover:bg-green-dim'
|
|
476
|
+
: 'w-full py-2.5 rounded-lg text-sm font-semibold transition-all bg-bg-3 text-text-0 border border-border-default hover:bg-bg-4 hover:border-border-hover'
|
|
477
|
+
|
|
478
|
+
const features = tier.features
|
|
479
|
+
.map(
|
|
480
|
+
(f) =>
|
|
481
|
+
` <li class="flex items-start gap-2 text-[12.5px] text-text-1">
|
|
482
|
+
<span class="text-green shrink-0 mt-0.5">${SVG_CHECK}</span>
|
|
483
|
+
${escapeHtml(f)}
|
|
484
|
+
</li>`
|
|
485
|
+
)
|
|
486
|
+
.join('\n')
|
|
487
|
+
|
|
488
|
+
const periodHtml = tier.period
|
|
489
|
+
? `<span class="text-text-3 text-sm">${escapeHtml(tier.period)}</span>`
|
|
490
|
+
: ''
|
|
491
|
+
const descHtml = tier.description
|
|
492
|
+
? ` <p class="text-[11px] text-text-3">${escapeHtml(tier.description)}</p>`
|
|
493
|
+
: ''
|
|
494
|
+
|
|
495
|
+
return ` <div class="relative rounded-xl p-6 flex flex-col transition-all ${featuredBorder}">
|
|
496
|
+
${featuredBadge}
|
|
497
|
+
<div class="mb-4">
|
|
498
|
+
<h3 class="text-sm font-semibold mb-1">${escapeHtml(tier.name)}</h3>
|
|
499
|
+
${descHtml}
|
|
500
|
+
</div>
|
|
501
|
+
<div class="mb-4">
|
|
502
|
+
<span class="text-3xl font-bold tracking-tight">${escapeHtml(tier.price)}</span>
|
|
503
|
+
${periodHtml}
|
|
504
|
+
</div>
|
|
505
|
+
<ul class="space-y-2 mb-6 flex-1">
|
|
506
|
+
${features}
|
|
507
|
+
</ul>
|
|
508
|
+
<button class="${btnClass}">${escapeHtml(tier.cta)}</button>
|
|
509
|
+
</div>`
|
|
510
|
+
})
|
|
511
|
+
.join('\n')
|
|
512
|
+
|
|
513
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
514
|
+
<div class="text-center mb-10">
|
|
515
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
516
|
+
${subtitleHtml}
|
|
517
|
+
</div>
|
|
518
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 max-w-4xl mx-auto">
|
|
519
|
+
${cards}
|
|
520
|
+
</div>
|
|
521
|
+
</section>`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderPricingComparison(block: BlockConfig): string {
|
|
525
|
+
const title = escapeHtml(prop(block.props, 'title', ''))
|
|
526
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
527
|
+
const tiers = prop<PricingTier[]>(block.props, 'tiers', defaultTiers)
|
|
528
|
+
|
|
529
|
+
const allFeatures = [...new Set(tiers.flatMap((t) => t.features))]
|
|
530
|
+
|
|
531
|
+
const subtitleHtml = subtitle
|
|
532
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
533
|
+
: ''
|
|
534
|
+
|
|
535
|
+
const headerCells = tiers
|
|
536
|
+
.map((tier) => {
|
|
537
|
+
const cls = tier.featured ? 'text-green' : 'text-text-0'
|
|
538
|
+
return ` <th class="py-3 px-3 text-center font-semibold ${cls}">
|
|
539
|
+
${escapeHtml(tier.name)}
|
|
540
|
+
<div class="text-lg font-bold mt-0.5">${escapeHtml(tier.price)}</div>
|
|
541
|
+
</th>`
|
|
542
|
+
})
|
|
543
|
+
.join('\n')
|
|
544
|
+
|
|
545
|
+
const bodyRows = allFeatures
|
|
546
|
+
.map((feature) => {
|
|
547
|
+
const cells = tiers
|
|
548
|
+
.map((tier) => {
|
|
549
|
+
const has = tier.features.includes(feature)
|
|
550
|
+
return has
|
|
551
|
+
? ` <td class="py-2.5 px-3 text-center"><span class="text-green inline-block">${SVG_CHECK}</span></td>`
|
|
552
|
+
: ` <td class="py-2.5 px-3 text-center text-text-3">-</td>`
|
|
553
|
+
})
|
|
554
|
+
.join('\n')
|
|
555
|
+
return ` <tr class="border-b border-border-subtle">
|
|
556
|
+
<td class="py-2.5 px-3 text-text-1">${escapeHtml(feature)}</td>
|
|
557
|
+
${cells}
|
|
558
|
+
</tr>`
|
|
559
|
+
})
|
|
560
|
+
.join('\n')
|
|
561
|
+
|
|
562
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
563
|
+
<div class="text-center mb-10">
|
|
564
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
565
|
+
${subtitleHtml}
|
|
566
|
+
</div>
|
|
567
|
+
<div class="max-w-3xl mx-auto overflow-x-auto">
|
|
568
|
+
<table class="w-full text-left text-[12.5px]">
|
|
569
|
+
<thead>
|
|
570
|
+
<tr class="border-b border-border-default">
|
|
571
|
+
<th class="py-3 px-3 text-text-3 font-medium">Feature</th>
|
|
572
|
+
${headerCells}
|
|
573
|
+
</tr>
|
|
574
|
+
</thead>
|
|
575
|
+
<tbody>
|
|
576
|
+
${bodyRows}
|
|
577
|
+
</tbody>
|
|
578
|
+
</table>
|
|
579
|
+
</div>
|
|
580
|
+
</section>`
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderPricing(block: BlockConfig): string {
|
|
584
|
+
switch (block.variant) {
|
|
585
|
+
case 'comparison':
|
|
586
|
+
return renderPricingComparison(block)
|
|
587
|
+
default:
|
|
588
|
+
return renderPricingSimple(block)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// CTA
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
function renderCtaSimple(block: BlockConfig): string {
|
|
597
|
+
const headline = escapeHtml(prop(block.props, 'headline', ''))
|
|
598
|
+
const subheadline = prop<string>(block.props, 'subheadline', '')
|
|
599
|
+
const buttonText = prop<string>(block.props, 'buttonText', '')
|
|
600
|
+
const buttonUrl = prop<string>(block.props, 'buttonUrl', '')
|
|
601
|
+
|
|
602
|
+
const subHtml = subheadline
|
|
603
|
+
? ` <p class="text-text-2 text-sm mb-6 max-w-md mx-auto">${escapeHtml(subheadline)}</p>`
|
|
604
|
+
: ''
|
|
605
|
+
|
|
606
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20 text-center relative overflow-hidden">
|
|
607
|
+
<div class="absolute inset-0 bg-gradient-to-b from-green/[0.06] via-green/[0.03] to-transparent pointer-events-none"></div>
|
|
608
|
+
<div class="relative z-10">
|
|
609
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-3">${headline}</h2>
|
|
610
|
+
${subHtml}
|
|
611
|
+
${renderLink(buttonText, buttonUrl, 'px-8 py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all inline-flex items-center gap-2')}
|
|
612
|
+
</div>
|
|
613
|
+
</section>`
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderCtaSplit(block: BlockConfig): string {
|
|
617
|
+
const headline = escapeHtml(prop(block.props, 'headline', ''))
|
|
618
|
+
const subheadline = prop<string>(block.props, 'subheadline', '')
|
|
619
|
+
const buttonText = prop<string>(block.props, 'buttonText', '')
|
|
620
|
+
const buttonUrl = prop<string>(block.props, 'buttonUrl', '')
|
|
621
|
+
|
|
622
|
+
const subHtml = subheadline
|
|
623
|
+
? ` <p class="text-text-2 text-sm">${escapeHtml(subheadline)}</p>`
|
|
624
|
+
: ''
|
|
625
|
+
|
|
626
|
+
return ` <section class="px-6 md:px-10 py-12 md:py-16">
|
|
627
|
+
<div class="flex flex-col md:flex-row items-center justify-between gap-6 p-8 rounded-xl bg-bg-2 border border-border-default relative overflow-hidden">
|
|
628
|
+
<div class="absolute inset-0 bg-gradient-to-r from-green/5 to-transparent pointer-events-none"></div>
|
|
629
|
+
<div class="relative z-10">
|
|
630
|
+
<h2 class="text-xl md:text-2xl font-bold tracking-tight mb-1">${headline}</h2>
|
|
631
|
+
${subHtml}
|
|
632
|
+
</div>
|
|
633
|
+
${renderLink(buttonText, buttonUrl, 'relative z-10 px-6 py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all shrink-0 inline-flex items-center gap-2')}
|
|
634
|
+
</div>
|
|
635
|
+
</section>`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function renderCta(block: BlockConfig): string {
|
|
639
|
+
switch (block.variant) {
|
|
640
|
+
case 'split':
|
|
641
|
+
return renderCtaSplit(block)
|
|
642
|
+
default:
|
|
643
|
+
return renderCtaSimple(block)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// Footer
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
function renderFooterSimple(block: BlockConfig): string {
|
|
652
|
+
const logo = escapeHtml(prop(block.props, 'logo', 'Brand'))
|
|
653
|
+
const logoImage = prop<string>(block.props, 'logoImage', '')
|
|
654
|
+
const copyright = escapeHtml(prop(block.props, 'copyright', ''))
|
|
655
|
+
const links = prop<string[]>(block.props, 'links', [])
|
|
656
|
+
|
|
657
|
+
const linksHtml = links
|
|
658
|
+
.map(
|
|
659
|
+
(l) =>
|
|
660
|
+
` <span class="text-[12px] text-text-3 hover:text-text-1 transition-colors cursor-pointer">${escapeHtml(l)}</span>`
|
|
661
|
+
)
|
|
662
|
+
.join('\n')
|
|
663
|
+
|
|
664
|
+
const footerLogoHtml = logoImage
|
|
665
|
+
? `<img src="${escapeHtml(logoImage)}" alt="${logo}" class="h-6 w-auto object-contain" />`
|
|
666
|
+
: `<div class="w-6 h-6 rounded-md bg-green/10 flex items-center justify-center"><div class="w-3 h-3 rounded-full bg-green"></div></div>`
|
|
667
|
+
|
|
668
|
+
return ` <footer class="px-6 md:px-10 py-8 border-t border-border-subtle">
|
|
669
|
+
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
|
670
|
+
<div class="flex items-center gap-2">
|
|
671
|
+
${footerLogoHtml}
|
|
672
|
+
<span class="text-sm font-semibold text-text-1">${logo}</span>
|
|
673
|
+
</div>
|
|
674
|
+
<div class="flex items-center gap-4">
|
|
675
|
+
${linksHtml}
|
|
676
|
+
</div>
|
|
677
|
+
<span class="text-[11px] text-text-3">${copyright}</span>
|
|
678
|
+
</div>
|
|
679
|
+
</footer>`
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function renderFooterMultiColumn(block: BlockConfig): string {
|
|
683
|
+
const logo = escapeHtml(prop(block.props, 'logo', 'Brand'))
|
|
684
|
+
const logoImage = prop<string>(block.props, 'logoImage', '')
|
|
685
|
+
const copyright = escapeHtml(prop(block.props, 'copyright', ''))
|
|
686
|
+
const links = prop<string[]>(block.props, 'links', [])
|
|
687
|
+
const columns = prop<{ title: string; links: string[] }[]>(
|
|
688
|
+
block.props,
|
|
689
|
+
'columns',
|
|
690
|
+
[
|
|
691
|
+
{ title: 'Product', links: ['Features', 'Pricing', 'Changelog', 'Roadmap'] },
|
|
692
|
+
{ title: 'Company', links: ['About', 'Blog', 'Careers', 'Press'] },
|
|
693
|
+
{
|
|
694
|
+
title: 'Resources',
|
|
695
|
+
links: ['Documentation', 'API Reference', 'Guides', 'Community'],
|
|
696
|
+
},
|
|
697
|
+
{ title: 'Legal', links: ['Privacy', 'Terms', 'Security', 'Cookie Policy'] },
|
|
698
|
+
]
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
const colsHtml = columns
|
|
702
|
+
.map((col) => {
|
|
703
|
+
const colLinks = col.links
|
|
704
|
+
.map(
|
|
705
|
+
(l) =>
|
|
706
|
+
` <li><span class="text-[12.5px] text-text-3 hover:text-text-1 transition-colors cursor-pointer">${escapeHtml(l)}</span></li>`
|
|
707
|
+
)
|
|
708
|
+
.join('\n')
|
|
709
|
+
return ` <div>
|
|
710
|
+
<h4 class="text-[11px] font-semibold uppercase tracking-wider text-text-2 mb-3">${escapeHtml(col.title)}</h4>
|
|
711
|
+
<ul class="space-y-2">
|
|
712
|
+
${colLinks}
|
|
713
|
+
</ul>
|
|
714
|
+
</div>`
|
|
715
|
+
})
|
|
716
|
+
.join('\n')
|
|
717
|
+
|
|
718
|
+
const bottomLinks = links
|
|
719
|
+
.map(
|
|
720
|
+
(l) =>
|
|
721
|
+
` <span class="text-[11px] text-text-3 hover:text-text-1 transition-colors cursor-pointer">${escapeHtml(l)}</span>`
|
|
722
|
+
)
|
|
723
|
+
.join('\n')
|
|
724
|
+
|
|
725
|
+
const mcLogoHtml = logoImage
|
|
726
|
+
? `<img src="${escapeHtml(logoImage)}" alt="${logo}" class="h-7 w-auto object-contain" />`
|
|
727
|
+
: `<div class="w-7 h-7 rounded-md bg-green/10 flex items-center justify-center"><div class="w-3.5 h-3.5 rounded-full bg-green"></div></div>`
|
|
728
|
+
|
|
729
|
+
return ` <footer class="px-6 md:px-10 py-12 border-t border-border-subtle">
|
|
730
|
+
<div class="grid grid-cols-2 lg:grid-cols-5 gap-8 mb-10">
|
|
731
|
+
<div class="col-span-2 lg:col-span-1">
|
|
732
|
+
<div class="flex items-center gap-2 mb-3">
|
|
733
|
+
${mcLogoHtml}
|
|
734
|
+
<span class="text-sm font-semibold">${logo}</span>
|
|
735
|
+
</div>
|
|
736
|
+
<p class="text-[12px] text-text-3 leading-relaxed max-w-[200px]">Build beautiful websites with structured JSON config.</p>
|
|
737
|
+
</div>
|
|
738
|
+
${colsHtml}
|
|
739
|
+
</div>
|
|
740
|
+
<div class="pt-6 border-t border-border-subtle flex flex-col md:flex-row items-center justify-between gap-3">
|
|
741
|
+
<span class="text-[11px] text-text-3">${copyright}</span>
|
|
742
|
+
<div class="flex gap-4">
|
|
743
|
+
${bottomLinks}
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
</footer>`
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderFooterMinimal(block: BlockConfig): string {
|
|
750
|
+
const copyright = escapeHtml(prop(block.props, 'copyright', ''))
|
|
751
|
+
const links = prop<string[]>(block.props, 'links', [])
|
|
752
|
+
|
|
753
|
+
const linksHtml = links
|
|
754
|
+
.map((l, i) => {
|
|
755
|
+
const sep = i < links.length - 1 ? '<span class="mx-1">|</span>' : ''
|
|
756
|
+
return `<span class="hover:text-text-1 transition-colors cursor-pointer">${escapeHtml(l)}</span>${sep}`
|
|
757
|
+
})
|
|
758
|
+
.join('')
|
|
759
|
+
|
|
760
|
+
const divider = links.length > 0 ? '<span class="mx-1">|</span>' : ''
|
|
761
|
+
|
|
762
|
+
return ` <footer class="px-6 md:px-10 py-6">
|
|
763
|
+
<div class="flex items-center justify-center gap-1.5 text-[11px] text-text-3">
|
|
764
|
+
<span>${copyright}</span>
|
|
765
|
+
${divider}
|
|
766
|
+
${linksHtml}
|
|
767
|
+
</div>
|
|
768
|
+
</footer>`
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function renderFooter(block: BlockConfig): string {
|
|
772
|
+
switch (block.variant) {
|
|
773
|
+
case 'multi-column':
|
|
774
|
+
return renderFooterMultiColumn(block)
|
|
775
|
+
case 'minimal':
|
|
776
|
+
return renderFooterMinimal(block)
|
|
777
|
+
default:
|
|
778
|
+
return renderFooterSimple(block)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// Testimonials (cards only, carousel skipped per spec)
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
interface TestimonialItem {
|
|
787
|
+
name: string
|
|
788
|
+
role: string
|
|
789
|
+
quote: string
|
|
790
|
+
rating?: number
|
|
791
|
+
avatar?: string
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const defaultTestimonials: TestimonialItem[] = [
|
|
795
|
+
{
|
|
796
|
+
name: 'Sarah Chen',
|
|
797
|
+
role: 'CEO at TechCorp',
|
|
798
|
+
quote: 'OpenPage completely changed how we build landing pages. The JSON config approach is genius.',
|
|
799
|
+
rating: 5,
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: 'Marcus Johnson',
|
|
803
|
+
role: 'Lead Developer',
|
|
804
|
+
quote: 'Finally, a tool where both designers and AI agents can work together seamlessly.',
|
|
805
|
+
rating: 5,
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: 'Emma Wilson',
|
|
809
|
+
role: 'Product Manager',
|
|
810
|
+
quote: 'We shipped our marketing site in half the time. The component library is incredible.',
|
|
811
|
+
rating: 4,
|
|
812
|
+
},
|
|
813
|
+
]
|
|
814
|
+
|
|
815
|
+
function renderTestimonials(block: BlockConfig): string {
|
|
816
|
+
const title = escapeHtml(
|
|
817
|
+
prop(block.props, 'title', 'What people say')
|
|
818
|
+
)
|
|
819
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
820
|
+
const items = prop<TestimonialItem[]>(block.props, 'items', defaultTestimonials)
|
|
821
|
+
|
|
822
|
+
const subtitleHtml = subtitle
|
|
823
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
824
|
+
: ''
|
|
825
|
+
|
|
826
|
+
const cards = items
|
|
827
|
+
.map((item) => {
|
|
828
|
+
const ratingHtml = item.rating ? ` ${starRatingHtml(item.rating)}` : ''
|
|
829
|
+
return ` <div class="bg-bg-2 border border-border-default rounded-xl p-5 transition-all hover:border-border-hover">
|
|
830
|
+
<span class="text-green/30 block mb-3">${SVG_QUOTE}</span>
|
|
831
|
+
<p class="text-[13px] text-text-1 leading-relaxed mb-4 italic">"${escapeHtml(item.quote)}"</p>
|
|
832
|
+
${ratingHtml}
|
|
833
|
+
<div class="flex items-center gap-3 mt-4 pt-4 border-t border-border-subtle">
|
|
834
|
+
${item.avatar ? `<img src="${escapeHtml(item.avatar)}" alt="${escapeHtml(item.name)}" class="w-9 h-9 rounded-full object-cover border border-border-default" />` : `<div class="w-9 h-9 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-[11px] font-semibold text-text-2">${initials(item.name)}</div>`}
|
|
835
|
+
<div>
|
|
836
|
+
<div class="text-[12.5px] font-semibold">${escapeHtml(item.name)}</div>
|
|
837
|
+
<div class="text-[11px] text-text-3">${escapeHtml(item.role)}</div>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
</div>`
|
|
841
|
+
})
|
|
842
|
+
.join('\n')
|
|
843
|
+
|
|
844
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
845
|
+
<div class="text-center mb-10">
|
|
846
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
847
|
+
${subtitleHtml}
|
|
848
|
+
</div>
|
|
849
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
850
|
+
${cards}
|
|
851
|
+
</div>
|
|
852
|
+
</section>`
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// Stats
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
interface StatItem {
|
|
860
|
+
value: string
|
|
861
|
+
label: string
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const defaultStats: StatItem[] = [
|
|
865
|
+
{ value: '10K+', label: 'Sites built' },
|
|
866
|
+
{ value: '99.9%', label: 'Uptime' },
|
|
867
|
+
{ value: '50ms', label: 'Avg. response' },
|
|
868
|
+
{ value: '4.9/5', label: 'User rating' },
|
|
869
|
+
]
|
|
870
|
+
|
|
871
|
+
function renderStatsGrid(block: BlockConfig): string {
|
|
872
|
+
const title = prop<string>(block.props, 'title', '')
|
|
873
|
+
const items = prop<StatItem[]>(block.props, 'items', defaultStats)
|
|
874
|
+
|
|
875
|
+
const titleHtml = title
|
|
876
|
+
? ` <h2 class="text-xl font-bold tracking-tight text-center mb-8">${escapeHtml(title)}</h2>`
|
|
877
|
+
: ''
|
|
878
|
+
|
|
879
|
+
const cards = items
|
|
880
|
+
.map(
|
|
881
|
+
(item) => ` <div class="text-center p-4 rounded-xl bg-bg-2 border border-border-default">
|
|
882
|
+
<div class="text-3xl md:text-4xl font-bold tracking-tight text-green mb-1">${escapeHtml(item.value)}</div>
|
|
883
|
+
<div class="text-[12px] text-text-2 font-medium">${escapeHtml(item.label)}</div>
|
|
884
|
+
</div>`
|
|
885
|
+
)
|
|
886
|
+
.join('\n')
|
|
887
|
+
|
|
888
|
+
return ` <section class="px-6 md:px-10 py-12 md:py-16">
|
|
889
|
+
${titleHtml}
|
|
890
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
891
|
+
${cards}
|
|
892
|
+
</div>
|
|
893
|
+
</section>`
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function renderStatsBar(block: BlockConfig): string {
|
|
897
|
+
const items = prop<StatItem[]>(block.props, 'items', defaultStats)
|
|
898
|
+
|
|
899
|
+
const statsHtml = items
|
|
900
|
+
.map(
|
|
901
|
+
(item) => ` <div class="text-center">
|
|
902
|
+
<div class="text-2xl md:text-3xl font-bold tracking-tight text-text-0 mb-0.5">${escapeHtml(item.value)}</div>
|
|
903
|
+
<div class="text-[11px] text-text-3 font-medium uppercase tracking-wider">${escapeHtml(item.label)}</div>
|
|
904
|
+
</div>`
|
|
905
|
+
)
|
|
906
|
+
.join('\n')
|
|
907
|
+
|
|
908
|
+
return ` <section class="px-6 md:px-10 py-10">
|
|
909
|
+
<div class="flex flex-wrap items-center justify-center gap-8 md:gap-12 py-6 px-4 rounded-xl bg-bg-2 border border-border-default">
|
|
910
|
+
${statsHtml}
|
|
911
|
+
</div>
|
|
912
|
+
</section>`
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function renderStats(block: BlockConfig): string {
|
|
916
|
+
switch (block.variant) {
|
|
917
|
+
case 'bar':
|
|
918
|
+
return renderStatsBar(block)
|
|
919
|
+
default:
|
|
920
|
+
return renderStatsGrid(block)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// FAQ (with inline JS for accordion toggle)
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
interface FaqItem {
|
|
929
|
+
question: string
|
|
930
|
+
answer: string
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const defaultFaqs: FaqItem[] = [
|
|
934
|
+
{
|
|
935
|
+
question: 'What is OpenPage?',
|
|
936
|
+
answer:
|
|
937
|
+
'OpenPage is a visual website builder that uses structured JSON config as the source of truth. Both humans and AI agents can edit the same config to build beautiful websites.',
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
question: 'How does the JSON config work?',
|
|
941
|
+
answer:
|
|
942
|
+
'Every website is represented as a JSON document with blocks, styles, and content. The visual editor reads and writes this JSON, and agents can make surgical edits via the API.',
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
question: 'Can I use my own components?',
|
|
946
|
+
answer:
|
|
947
|
+
'Yes! OpenPage supports custom components. You can build your own blocks following our component schema and register them in the block registry.',
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
question: 'Is it free to use?',
|
|
951
|
+
answer:
|
|
952
|
+
'OpenPage offers a free tier for personal projects with up to 5 blocks. Pro and Team plans unlock unlimited blocks, custom domains, and priority support.',
|
|
953
|
+
},
|
|
954
|
+
]
|
|
955
|
+
|
|
956
|
+
function renderFaq(block: BlockConfig): string {
|
|
957
|
+
const title = escapeHtml(
|
|
958
|
+
prop(block.props, 'title', 'Frequently Asked Questions')
|
|
959
|
+
)
|
|
960
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
961
|
+
const items = prop<FaqItem[]>(block.props, 'items', defaultFaqs)
|
|
962
|
+
|
|
963
|
+
const subtitleHtml = subtitle
|
|
964
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
965
|
+
: ''
|
|
966
|
+
|
|
967
|
+
const accordionItems = items
|
|
968
|
+
.map(
|
|
969
|
+
(item, i) => ` <div class="border-b border-border-subtle">
|
|
970
|
+
<button onclick="toggleFaq(this)" class="w-full flex items-center justify-between py-4 text-left group" aria-expanded="${i === 0 ? 'true' : 'false'}">
|
|
971
|
+
<span class="text-[13.5px] font-medium text-text-0 group-hover:text-green transition-colors">${escapeHtml(item.question)}</span>
|
|
972
|
+
<span class="faq-chevron text-text-3 shrink-0 ml-4 transition-transform duration-200${i === 0 ? ' rotate-180 text-green' : ''}">${SVG_CHEVRON_DOWN}</span>
|
|
973
|
+
</button>
|
|
974
|
+
<div class="faq-content overflow-hidden transition-all duration-200${i === 0 ? ' max-h-[500px] pb-4' : ' max-h-0'}">
|
|
975
|
+
<p class="text-[12.5px] text-text-2 leading-relaxed pr-8">${escapeHtml(item.answer)}</p>
|
|
976
|
+
</div>
|
|
977
|
+
</div>`
|
|
978
|
+
)
|
|
979
|
+
.join('\n')
|
|
980
|
+
|
|
981
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
982
|
+
<div class="text-center mb-10">
|
|
983
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
984
|
+
${subtitleHtml}
|
|
985
|
+
</div>
|
|
986
|
+
<div class="max-w-2xl mx-auto">
|
|
987
|
+
${accordionItems}
|
|
988
|
+
</div>
|
|
989
|
+
</section>`
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// Team
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
interface TeamMember {
|
|
997
|
+
name: string
|
|
998
|
+
role: string
|
|
999
|
+
avatar?: string
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const defaultMembers: TeamMember[] = [
|
|
1003
|
+
{ name: 'Alex Rivera', role: 'CEO & Founder' },
|
|
1004
|
+
{ name: 'Jordan Lee', role: 'CTO' },
|
|
1005
|
+
{ name: 'Sam Patel', role: 'Head of Design' },
|
|
1006
|
+
{ name: 'Casey Morgan', role: 'Lead Engineer' },
|
|
1007
|
+
]
|
|
1008
|
+
|
|
1009
|
+
function renderTeam(block: BlockConfig): string {
|
|
1010
|
+
const title = escapeHtml(prop(block.props, 'title', 'Meet the Team'))
|
|
1011
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
1012
|
+
const members = prop<TeamMember[]>(block.props, 'members', defaultMembers)
|
|
1013
|
+
|
|
1014
|
+
const subtitleHtml = subtitle
|
|
1015
|
+
? ` <p class="text-text-2 text-sm max-w-lg mx-auto">${escapeHtml(subtitle)}</p>`
|
|
1016
|
+
: ''
|
|
1017
|
+
|
|
1018
|
+
const cards = members
|
|
1019
|
+
.map(
|
|
1020
|
+
(m) => ` <div class="text-center group">
|
|
1021
|
+
${m.avatar ? `<img src="${escapeHtml(m.avatar)}" alt="${escapeHtml(m.name)}" class="w-20 h-20 mx-auto rounded-full object-cover border-2 border-border-default mb-3 transition-all group-hover:border-green/30" />` : `<div class="w-20 h-20 mx-auto rounded-full bg-bg-3 border-2 border-border-default flex items-center justify-center text-xl font-bold text-text-3 mb-3 transition-all group-hover:border-green/30">${initials(m.name)}</div>`}
|
|
1022
|
+
<h3 class="text-sm font-semibold">${escapeHtml(m.name)}</h3>
|
|
1023
|
+
<p class="text-[11px] text-text-3 mt-0.5">${escapeHtml(m.role)}</p>
|
|
1024
|
+
</div>`
|
|
1025
|
+
)
|
|
1026
|
+
.join('\n')
|
|
1027
|
+
|
|
1028
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
1029
|
+
<div class="text-center mb-10">
|
|
1030
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
1031
|
+
${subtitleHtml}
|
|
1032
|
+
</div>
|
|
1033
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
|
1034
|
+
${cards}
|
|
1035
|
+
</div>
|
|
1036
|
+
</section>`
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
// Contact
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
function renderContact(block: BlockConfig): string {
|
|
1044
|
+
const title = escapeHtml(prop(block.props, 'title', 'Get in Touch'))
|
|
1045
|
+
const subtitle = prop<string>(block.props, 'subtitle', '')
|
|
1046
|
+
|
|
1047
|
+
const subtitleHtml = subtitle
|
|
1048
|
+
? ` <p class="text-text-2 text-sm">${escapeHtml(subtitle)}</p>`
|
|
1049
|
+
: ''
|
|
1050
|
+
|
|
1051
|
+
return ` <section class="px-6 md:px-10 py-16 md:py-20">
|
|
1052
|
+
<div class="max-w-lg mx-auto">
|
|
1053
|
+
<div class="text-center mb-8">
|
|
1054
|
+
<h2 class="text-2xl md:text-3xl font-bold tracking-tight mb-2">${title}</h2>
|
|
1055
|
+
${subtitleHtml}
|
|
1056
|
+
</div>
|
|
1057
|
+
<form onsubmit="return false" class="space-y-4">
|
|
1058
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
1059
|
+
<div>
|
|
1060
|
+
<label class="block text-[11.5px] text-text-2 mb-1.5 font-medium">Name</label>
|
|
1061
|
+
<input type="text" placeholder="Your name" class="w-full px-3 py-2.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[13px] outline-none focus:border-green placeholder:text-text-3 transition-colors" />
|
|
1062
|
+
</div>
|
|
1063
|
+
<div>
|
|
1064
|
+
<label class="block text-[11.5px] text-text-2 mb-1.5 font-medium">Email</label>
|
|
1065
|
+
<input type="email" placeholder="you@example.com" class="w-full px-3 py-2.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[13px] outline-none focus:border-green placeholder:text-text-3 transition-colors" />
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
<div>
|
|
1069
|
+
<label class="block text-[11.5px] text-text-2 mb-1.5 font-medium">Message</label>
|
|
1070
|
+
<textarea rows="4" placeholder="How can we help?" class="w-full px-3 py-2.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[13px] outline-none focus:border-green placeholder:text-text-3 resize-y transition-colors"></textarea>
|
|
1071
|
+
</div>
|
|
1072
|
+
<button type="submit" class="w-full py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all flex items-center justify-center gap-2">
|
|
1073
|
+
${SVG_SEND}
|
|
1074
|
+
Send Message
|
|
1075
|
+
</button>
|
|
1076
|
+
</form>
|
|
1077
|
+
</div>
|
|
1078
|
+
</section>`
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
// Newsletter
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
|
|
1085
|
+
function renderNewsletter(block: BlockConfig): string {
|
|
1086
|
+
const title = escapeHtml(
|
|
1087
|
+
prop(block.props, 'title', 'Stay in the loop')
|
|
1088
|
+
)
|
|
1089
|
+
const subtitle = escapeHtml(
|
|
1090
|
+
prop(
|
|
1091
|
+
block.props,
|
|
1092
|
+
'subtitle',
|
|
1093
|
+
'Get updates on new features and releases. No spam, ever.'
|
|
1094
|
+
)
|
|
1095
|
+
)
|
|
1096
|
+
const buttonText = escapeHtml(
|
|
1097
|
+
prop(block.props, 'buttonText', 'Subscribe')
|
|
1098
|
+
)
|
|
1099
|
+
const socialProof = prop<string>(block.props, 'socialProof', '')
|
|
1100
|
+
|
|
1101
|
+
const proofText = socialProof
|
|
1102
|
+
? escapeHtml(socialProof)
|
|
1103
|
+
: 'Join 2,000+ developers and designers'
|
|
1104
|
+
|
|
1105
|
+
return ` <section class="px-6 md:px-10 py-12 md:py-16">
|
|
1106
|
+
<div class="max-w-xl mx-auto text-center">
|
|
1107
|
+
<div class="w-12 h-12 rounded-xl bg-green/10 border border-green/20 flex items-center justify-center text-green mx-auto mb-4">
|
|
1108
|
+
${SVG_MAIL}
|
|
1109
|
+
</div>
|
|
1110
|
+
<h2 class="text-xl md:text-2xl font-bold tracking-tight mb-2">${title}</h2>
|
|
1111
|
+
<p class="text-text-2 text-sm mb-6">${subtitle}</p>
|
|
1112
|
+
<form onsubmit="return false" class="flex gap-2 max-w-sm mx-auto">
|
|
1113
|
+
<input type="email" placeholder="you@example.com" class="flex-1 px-4 py-2.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[13px] outline-none focus:border-green placeholder:text-text-3 transition-colors" />
|
|
1114
|
+
<button type="submit" class="px-5 py-2.5 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all shrink-0">${buttonText}</button>
|
|
1115
|
+
</form>
|
|
1116
|
+
<p class="text-[11px] text-text-3 mt-3">${proofText}</p>
|
|
1117
|
+
</div>
|
|
1118
|
+
</section>`
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Logo Cloud
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
function renderLogoCloud(block: BlockConfig): string {
|
|
1126
|
+
const title = escapeHtml(
|
|
1127
|
+
prop(block.props, 'title', 'Trusted by teams at')
|
|
1128
|
+
)
|
|
1129
|
+
const logos = prop<string[]>(block.props, 'logos', [
|
|
1130
|
+
'Vercel',
|
|
1131
|
+
'Stripe',
|
|
1132
|
+
'GitHub',
|
|
1133
|
+
'Figma',
|
|
1134
|
+
'Notion',
|
|
1135
|
+
'Linear',
|
|
1136
|
+
])
|
|
1137
|
+
|
|
1138
|
+
const logosHtml = logos
|
|
1139
|
+
.map(
|
|
1140
|
+
(name) => ` <div class="group cursor-pointer transition-all">
|
|
1141
|
+
<div class="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-all group-hover:scale-105">
|
|
1142
|
+
<span class="text-text-3 group-hover:text-green transition-colors">${logoPlaceholderSvg(name)}</span>
|
|
1143
|
+
<span class="text-sm font-semibold text-text-3 group-hover:text-text-0 tracking-tight transition-colors">${escapeHtml(name)}</span>
|
|
1144
|
+
</div>
|
|
1145
|
+
</div>`
|
|
1146
|
+
)
|
|
1147
|
+
.join('\n')
|
|
1148
|
+
|
|
1149
|
+
return ` <section class="px-6 md:px-10 py-10 md:py-14">
|
|
1150
|
+
<p class="text-center text-[11px] font-medium uppercase tracking-widest text-text-3 mb-6">${title}</p>
|
|
1151
|
+
<div class="flex flex-wrap items-center justify-center gap-8 md:gap-12">
|
|
1152
|
+
${logosHtml}
|
|
1153
|
+
</div>
|
|
1154
|
+
</section>`
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ---------------------------------------------------------------------------
|
|
1158
|
+
// Block dispatcher
|
|
1159
|
+
// ---------------------------------------------------------------------------
|
|
1160
|
+
|
|
1161
|
+
function renderBlock(block: BlockConfig): string {
|
|
1162
|
+
switch (block.type) {
|
|
1163
|
+
case 'navbar':
|
|
1164
|
+
return renderNavbar(block)
|
|
1165
|
+
case 'hero':
|
|
1166
|
+
return renderHero(block)
|
|
1167
|
+
case 'features':
|
|
1168
|
+
return renderFeatures(block)
|
|
1169
|
+
case 'pricing':
|
|
1170
|
+
return renderPricing(block)
|
|
1171
|
+
case 'cta':
|
|
1172
|
+
return renderCta(block)
|
|
1173
|
+
case 'footer':
|
|
1174
|
+
return renderFooter(block)
|
|
1175
|
+
case 'testimonials':
|
|
1176
|
+
return renderTestimonials(block)
|
|
1177
|
+
case 'stats':
|
|
1178
|
+
return renderStats(block)
|
|
1179
|
+
case 'faq':
|
|
1180
|
+
return renderFaq(block)
|
|
1181
|
+
case 'team':
|
|
1182
|
+
return renderTeam(block)
|
|
1183
|
+
case 'contact':
|
|
1184
|
+
return renderContact(block)
|
|
1185
|
+
case 'newsletter':
|
|
1186
|
+
return renderNewsletter(block)
|
|
1187
|
+
case 'logocloud':
|
|
1188
|
+
return renderLogoCloud(block)
|
|
1189
|
+
default:
|
|
1190
|
+
return ` <!-- Unknown block type: ${escapeHtml(block.type)} -->`
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
// Main export
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
|
|
1198
|
+
export function exportSiteToHTML(config: SiteConfig, options?: ExportSiteOptions): string {
|
|
1199
|
+
const theme = resolveTheme(config.theme)
|
|
1200
|
+
const fonts = [theme.fontSans, theme.fontDisplay, theme.fontMono]
|
|
1201
|
+
const fontUrl = googleFontUrl(fonts)
|
|
1202
|
+
const settings = options?.settings
|
|
1203
|
+
|
|
1204
|
+
const hasFaq = config.blocks.some((b) => b.type === 'faq')
|
|
1205
|
+
|
|
1206
|
+
const blocksHtml = config.blocks.map((b) => renderBlock(b)).join('\n\n')
|
|
1207
|
+
|
|
1208
|
+
const pageTitle = (settings?.seoTitle || settings?.siteName || config.name || 'Website').trim()
|
|
1209
|
+
const pageDescription = (settings?.seoDescription || settings?.siteDescription || '').trim()
|
|
1210
|
+
const ogTitle = (settings?.seoTitle || settings?.siteName || pageTitle).trim()
|
|
1211
|
+
const ogDescription = (settings?.seoDescription || settings?.siteDescription || pageDescription).trim()
|
|
1212
|
+
const ogImage = (settings?.ogImageUrl || '').trim()
|
|
1213
|
+
const faviconUrl = (settings?.faviconUrl || '').trim()
|
|
1214
|
+
const gaId = (settings?.gaId || '').trim()
|
|
1215
|
+
const posthogKey = (settings?.posthogKey || '').trim()
|
|
1216
|
+
const lang = normalizeLanguage(settings?.language)
|
|
1217
|
+
|
|
1218
|
+
const descriptionMeta = pageDescription
|
|
1219
|
+
? ` <meta name="description" content="${escapeHtml(pageDescription)}" />\n`
|
|
1220
|
+
: ''
|
|
1221
|
+
const ogDescriptionMeta = ogDescription
|
|
1222
|
+
? ` <meta property="og:description" content="${escapeHtml(ogDescription)}" />\n`
|
|
1223
|
+
: ''
|
|
1224
|
+
const ogImageMeta = ogImage
|
|
1225
|
+
? ` <meta property="og:image" content="${escapeHtml(ogImage)}" />\n`
|
|
1226
|
+
: ''
|
|
1227
|
+
const faviconLink = faviconUrl
|
|
1228
|
+
? ` <link rel="icon" href="${escapeHtml(faviconUrl)}" />\n`
|
|
1229
|
+
: ''
|
|
1230
|
+
|
|
1231
|
+
const gaScript = gaId
|
|
1232
|
+
? `
|
|
1233
|
+
<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(gaId)}"></script>
|
|
1234
|
+
<script>
|
|
1235
|
+
window.dataLayer = window.dataLayer || [];
|
|
1236
|
+
function gtag(){dataLayer.push(arguments);}
|
|
1237
|
+
gtag('js', new Date());
|
|
1238
|
+
gtag('config', ${JSON.stringify(gaId)});
|
|
1239
|
+
</script>`
|
|
1240
|
+
: ''
|
|
1241
|
+
|
|
1242
|
+
const posthogScript = posthogKey
|
|
1243
|
+
? `
|
|
1244
|
+
<script>
|
|
1245
|
+
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");
|
|
1246
|
+
2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}
|
|
1247
|
+
(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",
|
|
1248
|
+
(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",
|
|
1249
|
+
u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},
|
|
1250
|
+
u.people.toString=function(){return u.toString(1)+".people"},o="init capture register register_once alias unregister identify set_config reset opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing".split(" "),
|
|
1251
|
+
n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
|
1252
|
+
posthog.init(${JSON.stringify(posthogKey)}, { api_host: 'https://us.i.posthog.com' });
|
|
1253
|
+
</script>`
|
|
1254
|
+
: ''
|
|
1255
|
+
|
|
1256
|
+
const faqScript = hasFaq
|
|
1257
|
+
? `
|
|
1258
|
+
<script>
|
|
1259
|
+
function toggleFaq(btn) {
|
|
1260
|
+
var content = btn.nextElementSibling;
|
|
1261
|
+
var chevron = btn.querySelector('.faq-chevron');
|
|
1262
|
+
var isOpen = btn.getAttribute('aria-expanded') === 'true';
|
|
1263
|
+
|
|
1264
|
+
// Close all
|
|
1265
|
+
var items = btn.closest('section').querySelectorAll('[aria-expanded]');
|
|
1266
|
+
items.forEach(function(b) {
|
|
1267
|
+
b.setAttribute('aria-expanded', 'false');
|
|
1268
|
+
b.nextElementSibling.style.maxHeight = '0';
|
|
1269
|
+
b.nextElementSibling.style.paddingBottom = '0';
|
|
1270
|
+
var c = b.querySelector('.faq-chevron');
|
|
1271
|
+
if (c) { c.style.transform = ''; c.classList.remove('text-green'); c.classList.add('text-text-3'); }
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
// Toggle current
|
|
1275
|
+
if (!isOpen) {
|
|
1276
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
1277
|
+
content.style.maxHeight = '500px';
|
|
1278
|
+
content.style.paddingBottom = '1rem';
|
|
1279
|
+
if (chevron) { chevron.style.transform = 'rotate(180deg)'; chevron.classList.add('text-green'); chevron.classList.remove('text-text-3'); }
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
</script>`
|
|
1283
|
+
: ''
|
|
1284
|
+
|
|
1285
|
+
return `<!DOCTYPE html>
|
|
1286
|
+
<html lang="${escapeHtml(lang)}">
|
|
1287
|
+
<head>
|
|
1288
|
+
<meta charset="UTF-8" />
|
|
1289
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1290
|
+
<title>${escapeHtml(pageTitle)}</title>
|
|
1291
|
+
${descriptionMeta} <meta property="og:title" content="${escapeHtml(ogTitle)}" />
|
|
1292
|
+
${ogDescriptionMeta}${ogImageMeta}${faviconLink}
|
|
1293
|
+
|
|
1294
|
+
<!-- Google Fonts -->
|
|
1295
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
1296
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
1297
|
+
<link rel="stylesheet" href="${fontUrl}" />
|
|
1298
|
+
|
|
1299
|
+
<!-- Tailwind CSS (Play CDN) -->
|
|
1300
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
1301
|
+
<script>
|
|
1302
|
+
tailwind.config = {
|
|
1303
|
+
theme: {
|
|
1304
|
+
extend: {
|
|
1305
|
+
colors: {
|
|
1306
|
+
'bg-0': '${theme.bg0}',
|
|
1307
|
+
'bg-1': '${theme.bg1}',
|
|
1308
|
+
'bg-2': '${theme.bg2}',
|
|
1309
|
+
'bg-3': '${theme.bg3}',
|
|
1310
|
+
'bg-4': '${theme.bg4}',
|
|
1311
|
+
'bg-5': '${theme.bg5}',
|
|
1312
|
+
'text-0': '${theme.text0}',
|
|
1313
|
+
'text-1': '${theme.text1}',
|
|
1314
|
+
'text-2': '${theme.text2}',
|
|
1315
|
+
'text-3': '${theme.text3}',
|
|
1316
|
+
'green': {
|
|
1317
|
+
DEFAULT: '${theme.accent}',
|
|
1318
|
+
dim: '${theme.accentDim}',
|
|
1319
|
+
},
|
|
1320
|
+
'border-default': '${theme.borderDefault}',
|
|
1321
|
+
'border-subtle': '${theme.borderSubtle}',
|
|
1322
|
+
'border-hover': '${theme.borderHover}',
|
|
1323
|
+
},
|
|
1324
|
+
fontFamily: {
|
|
1325
|
+
sans: ['"${theme.fontSans}"', '-apple-system', 'system-ui', 'sans-serif'],
|
|
1326
|
+
display: ['"${theme.fontDisplay}"', 'system-ui', 'sans-serif'],
|
|
1327
|
+
mono: ['"${theme.fontMono}"', 'ui-monospace', 'monospace'],
|
|
1328
|
+
},
|
|
1329
|
+
borderRadius: {
|
|
1330
|
+
DEFAULT: '${theme.radius}px',
|
|
1331
|
+
lg: '${theme.radiusLg}px',
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
}
|
|
1336
|
+
</script>
|
|
1337
|
+
|
|
1338
|
+
<!-- Theme CSS Custom Properties -->
|
|
1339
|
+
<style>
|
|
1340
|
+
:root {
|
|
1341
|
+
--color-bg-0: ${theme.bg0};
|
|
1342
|
+
--color-bg-1: ${theme.bg1};
|
|
1343
|
+
--color-bg-2: ${theme.bg2};
|
|
1344
|
+
--color-bg-3: ${theme.bg3};
|
|
1345
|
+
--color-bg-4: ${theme.bg4};
|
|
1346
|
+
--color-bg-5: ${theme.bg5};
|
|
1347
|
+
--color-text-0: ${theme.text0};
|
|
1348
|
+
--color-text-1: ${theme.text1};
|
|
1349
|
+
--color-text-2: ${theme.text2};
|
|
1350
|
+
--color-text-3: ${theme.text3};
|
|
1351
|
+
--color-green: ${theme.accent};
|
|
1352
|
+
--color-green-dim: ${theme.accentDim};
|
|
1353
|
+
--color-border-default: ${theme.borderDefault};
|
|
1354
|
+
--color-border-subtle: ${theme.borderSubtle};
|
|
1355
|
+
--color-border-hover: ${theme.borderHover};
|
|
1356
|
+
--font-sans: "${theme.fontSans}", -apple-system, system-ui, sans-serif;
|
|
1357
|
+
--font-display: "${theme.fontDisplay}", system-ui, sans-serif;
|
|
1358
|
+
--font-mono: "${theme.fontMono}", ui-monospace, monospace;
|
|
1359
|
+
--radius-default: ${theme.radius}px;
|
|
1360
|
+
--radius-lg: ${theme.radiusLg}px;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1364
|
+
|
|
1365
|
+
body {
|
|
1366
|
+
margin: 0;
|
|
1367
|
+
background-color: ${theme.bg0};
|
|
1368
|
+
color: ${theme.text0};
|
|
1369
|
+
font-family: "${theme.fontSans}", -apple-system, system-ui, sans-serif;
|
|
1370
|
+
-webkit-font-smoothing: antialiased;
|
|
1371
|
+
-moz-osx-font-smoothing: grayscale;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
h1, h2, h3, h4, h5, h6 {
|
|
1375
|
+
font-family: "${theme.fontDisplay}", system-ui, sans-serif;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
.faq-content {
|
|
1379
|
+
transition: max-height 0.2s ease, padding-bottom 0.2s ease;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
.faq-chevron {
|
|
1383
|
+
transition: transform 0.2s ease;
|
|
1384
|
+
}
|
|
1385
|
+
</style>
|
|
1386
|
+
${gaScript}
|
|
1387
|
+
${posthogScript}
|
|
1388
|
+
</head>
|
|
1389
|
+
<body>
|
|
1390
|
+
|
|
1391
|
+
${blocksHtml}
|
|
1392
|
+
${faqScript}
|
|
1393
|
+
</body>
|
|
1394
|
+
</html>`
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
export async function exportToHTML(
|
|
1398
|
+
config: SiteConfig,
|
|
1399
|
+
options?: ExportSiteOptions
|
|
1400
|
+
): Promise<string> {
|
|
1401
|
+
return exportSiteToHTML(config, options)
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
export function downloadHTML(html: string, filename: string): void {
|
|
1405
|
+
const blob = new Blob([html], { type: 'text/html' })
|
|
1406
|
+
const url = URL.createObjectURL(blob)
|
|
1407
|
+
const a = document.createElement('a')
|
|
1408
|
+
a.href = url
|
|
1409
|
+
a.download = filename
|
|
1410
|
+
document.body.appendChild(a)
|
|
1411
|
+
a.click()
|
|
1412
|
+
document.body.removeChild(a)
|
|
1413
|
+
URL.revokeObjectURL(url)
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export function previewHTML(html: string): void {
|
|
1417
|
+
const previewWindow = window.open('', '_blank', 'noopener,noreferrer')
|
|
1418
|
+
if (!previewWindow) {
|
|
1419
|
+
throw new Error('Preview popup was blocked')
|
|
1420
|
+
}
|
|
1421
|
+
previewWindow.document.open()
|
|
1422
|
+
previewWindow.document.write(html)
|
|
1423
|
+
previewWindow.document.close()
|
|
1424
|
+
}
|