@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
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siteable/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Siteable core engine — blocks, visual editor, config store, theme presets, HTML export. JSON-first website builder engine.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "Ha Nguyen",
|
|
8
|
+
"private": false,
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/Siteable/siteable-core.git"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"NOTICE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "vite build",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": "^19.2.0",
|
|
29
|
+
"react-dom": "^19.2.0",
|
|
30
|
+
"zustand": "^5.0.11",
|
|
31
|
+
"immer": "^11.1.4",
|
|
32
|
+
"@dnd-kit/core": "^6.3.1",
|
|
33
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
34
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
35
|
+
"sonner": "^2.0.7",
|
|
36
|
+
"lucide-react": "^0.575.0",
|
|
37
|
+
"tailwindcss": "^4.2.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/react": "^19.2.7",
|
|
41
|
+
"@types/react-dom": "^19.2.3",
|
|
42
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
43
|
+
"jsdom": "^30.1.0",
|
|
44
|
+
"typescript": "~5.9.3",
|
|
45
|
+
"vite": "^7.3.1",
|
|
46
|
+
"vite-plugin-dts": "^4.5.4",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"website-builder",
|
|
51
|
+
"page-builder",
|
|
52
|
+
"visual-editor",
|
|
53
|
+
"drag-and-drop",
|
|
54
|
+
"ai-website-builder",
|
|
55
|
+
"json",
|
|
56
|
+
"react",
|
|
57
|
+
"typescript",
|
|
58
|
+
"tailwindcss",
|
|
59
|
+
"landing-page",
|
|
60
|
+
"no-code",
|
|
61
|
+
"open-source",
|
|
62
|
+
"self-hosted",
|
|
63
|
+
"engine",
|
|
64
|
+
"blocks"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type ReactNode, useRef, useEffect } from 'react'
|
|
2
|
+
import { Copy, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { toast } from 'sonner'
|
|
4
|
+
import { useConfigStore } from '@/store/configStore'
|
|
5
|
+
import { useEditorStore } from '@/store/editorStore'
|
|
6
|
+
import { useScrollReveal } from '@/lib/useScrollReveal'
|
|
7
|
+
import type { BlockConfig } from './types'
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
block: BlockConfig
|
|
11
|
+
isSelected: boolean
|
|
12
|
+
onSelect: () => void
|
|
13
|
+
children: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function BlockWrapper({ block, isSelected, onSelect, children }: Props) {
|
|
17
|
+
const blocks = useConfigStore((s) => {
|
|
18
|
+
const pages = s.config.pages
|
|
19
|
+
if (!pages || pages.length === 0) return s.config.blocks
|
|
20
|
+
const page = pages.find((p) => p.id === s.activePageId) ?? pages[0]
|
|
21
|
+
return page.blocks
|
|
22
|
+
})
|
|
23
|
+
const { duplicateBlock, removeBlock, moveBlock } = useConfigStore()
|
|
24
|
+
const { selectedBlockId, selectBlock } = useEditorStore()
|
|
25
|
+
const previewMode = useEditorStore((s) => s.previewMode)
|
|
26
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
27
|
+
const { ref: revealRef, isRevealed } = useScrollReveal(!previewMode)
|
|
28
|
+
|
|
29
|
+
const index = blocks.findIndex((b) => b.id === block.id)
|
|
30
|
+
const isFirst = index === 0
|
|
31
|
+
const isLast = index === blocks.length - 1
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (isSelected && scrollRef.current) {
|
|
35
|
+
scrollRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
36
|
+
}
|
|
37
|
+
}, [isSelected])
|
|
38
|
+
|
|
39
|
+
if (previewMode) {
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
ref={revealRef}
|
|
43
|
+
className={isRevealed ? 'scroll-revealed' : ''}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={(el) => {
|
|
53
|
+
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el
|
|
54
|
+
;(revealRef as React.MutableRefObject<HTMLDivElement | null>).current = el
|
|
55
|
+
}}
|
|
56
|
+
onClick={(e) => {
|
|
57
|
+
e.stopPropagation()
|
|
58
|
+
onSelect()
|
|
59
|
+
}}
|
|
60
|
+
className={`scroll-revealed relative cursor-pointer border-b border-border-subtle group transition-[opacity,transform] duration-500 ${
|
|
61
|
+
isRevealed ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
|
|
62
|
+
} ${
|
|
63
|
+
isSelected
|
|
64
|
+
? 'bg-green-glow2 outline outline-2 outline-green -outline-offset-2 rounded animate-select-pulse'
|
|
65
|
+
: 'hover:bg-green-glow2'
|
|
66
|
+
}`}
|
|
67
|
+
role="button"
|
|
68
|
+
aria-label={`${block.type} block${isSelected ? ', selected' : ''}`}
|
|
69
|
+
aria-selected={isSelected}
|
|
70
|
+
tabIndex={0}
|
|
71
|
+
onKeyDown={(e) => {
|
|
72
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
onSelect()
|
|
75
|
+
}
|
|
76
|
+
}}
|
|
77
|
+
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
|
|
78
|
+
>
|
|
79
|
+
{/* Block type tag */}
|
|
80
|
+
<span
|
|
81
|
+
className={`absolute top-1.5 left-1.5 text-[9px] font-semibold uppercase tracking-wider text-green bg-green-glow px-1.5 py-0.5 rounded transition-opacity z-10 ${
|
|
82
|
+
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
83
|
+
}`}
|
|
84
|
+
>
|
|
85
|
+
{block.type}
|
|
86
|
+
</span>
|
|
87
|
+
|
|
88
|
+
{/* Action buttons */}
|
|
89
|
+
<div
|
|
90
|
+
className={`absolute top-1.5 right-1.5 flex gap-0.5 z-10 transition-opacity ${
|
|
91
|
+
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
92
|
+
}`}
|
|
93
|
+
>
|
|
94
|
+
{!isFirst && (
|
|
95
|
+
<button
|
|
96
|
+
onClick={(e) => { e.stopPropagation(); moveBlock(index, index - 1) }}
|
|
97
|
+
className="w-6 h-6 rounded bg-bg-2/80 border border-border-default backdrop-blur-sm flex items-center justify-center text-text-3 hover:text-text-0 hover:bg-bg-3 transition-colors"
|
|
98
|
+
title="Move up"
|
|
99
|
+
aria-label={`Move ${block.type} block up`}
|
|
100
|
+
>
|
|
101
|
+
<ChevronUp size={12} />
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
{!isLast && (
|
|
105
|
+
<button
|
|
106
|
+
onClick={(e) => { e.stopPropagation(); moveBlock(index, index + 1) }}
|
|
107
|
+
className="w-6 h-6 rounded bg-bg-2/80 border border-border-default backdrop-blur-sm flex items-center justify-center text-text-3 hover:text-text-0 hover:bg-bg-3 transition-colors"
|
|
108
|
+
title="Move down"
|
|
109
|
+
aria-label={`Move ${block.type} block down`}
|
|
110
|
+
>
|
|
111
|
+
<ChevronDown size={12} />
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
<button
|
|
115
|
+
onClick={(e) => { e.stopPropagation(); duplicateBlock(block.id) }}
|
|
116
|
+
className="w-6 h-6 rounded bg-bg-2/80 border border-border-default backdrop-blur-sm flex items-center justify-center text-text-3 hover:text-text-0 hover:bg-bg-3 transition-colors"
|
|
117
|
+
title="Duplicate"
|
|
118
|
+
aria-label={`Duplicate ${block.type} block`}
|
|
119
|
+
>
|
|
120
|
+
<Copy size={12} />
|
|
121
|
+
</button>
|
|
122
|
+
<button
|
|
123
|
+
onClick={(e) => {
|
|
124
|
+
e.stopPropagation()
|
|
125
|
+
if (selectedBlockId === block.id) selectBlock(null)
|
|
126
|
+
removeBlock(block.id)
|
|
127
|
+
toast('Block removed', {
|
|
128
|
+
action: {
|
|
129
|
+
label: 'Undo',
|
|
130
|
+
onClick: () => {
|
|
131
|
+
useConfigStore.getState().undo()
|
|
132
|
+
toast('Block restored')
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
duration: 3000,
|
|
136
|
+
})
|
|
137
|
+
}}
|
|
138
|
+
className="w-6 h-6 rounded bg-bg-2/80 border border-border-default backdrop-blur-sm flex items-center justify-center text-text-3 hover:text-status-red hover:bg-status-red/10 transition-colors"
|
|
139
|
+
title="Delete"
|
|
140
|
+
aria-label={`Delete ${block.type} block`}
|
|
141
|
+
>
|
|
142
|
+
<Trash2 size={12} />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{children}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ArrowRight } from 'lucide-react'
|
|
2
|
+
import type { BlockConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
export function BannerBlock({ block }: { block: BlockConfig }) {
|
|
5
|
+
const { variant, props } = block
|
|
6
|
+
const text = (props.text as string) || 'Announcement'
|
|
7
|
+
const linkText = props.linkText as string | undefined
|
|
8
|
+
const linkUrl = (props.linkUrl as string) || '#'
|
|
9
|
+
|
|
10
|
+
if (variant === 'bar') {
|
|
11
|
+
return (
|
|
12
|
+
<div className="px-6 py-3 @lg:px-9">
|
|
13
|
+
<div className="reveal-fade-up reveal-d1 flex items-center justify-between gap-4 px-5 py-3.5 rounded-lg border border-border-default bg-bg-2">
|
|
14
|
+
<span className="text-[13px] text-text-1">{text}</span>
|
|
15
|
+
{linkText && (
|
|
16
|
+
<a href={linkUrl} className="text-[13px] font-medium text-green hover:text-green-dim transition-colors flex items-center gap-1 shrink-0">
|
|
17
|
+
{linkText}
|
|
18
|
+
<ArrowRight size={13} />
|
|
19
|
+
</a>
|
|
20
|
+
)}
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ribbon (default)
|
|
27
|
+
return (
|
|
28
|
+
<div className="reveal-fade-up reveal-d1 flex items-center justify-center gap-3 px-4 py-2.5 bg-green/10 border-b border-green/20">
|
|
29
|
+
<span className="text-[12.5px] text-text-0 font-medium">{text}</span>
|
|
30
|
+
{linkText && (
|
|
31
|
+
<a href={linkUrl} className="text-[12.5px] font-semibold text-green hover:text-green-dim transition-colors flex items-center gap-1">
|
|
32
|
+
{linkText}
|
|
33
|
+
<ArrowRight size={12} />
|
|
34
|
+
</a>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { toast } from 'sonner'
|
|
2
|
+
import type { BlockConfig } from '../types'
|
|
3
|
+
import { Send } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
interface ContactProps {
|
|
6
|
+
title?: string
|
|
7
|
+
subtitle?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ContactBlock({ block }: { block: BlockConfig }) {
|
|
11
|
+
const props = block.props as unknown as ContactProps
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<section className="px-6 @md:px-10 py-16 @md:py-20">
|
|
15
|
+
<div className="max-w-lg mx-auto">
|
|
16
|
+
<div className="reveal-fade-up reveal-d1 text-center mb-8">
|
|
17
|
+
<h2 className="text-2xl @md:text-3xl font-bold tracking-tight mb-2">
|
|
18
|
+
{props.title || 'Get in Touch'}
|
|
19
|
+
</h2>
|
|
20
|
+
{props.subtitle && (
|
|
21
|
+
<p className="text-text-2 text-sm">{props.subtitle}</p>
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div className="reveal-fade-up reveal-d2 space-y-4">
|
|
26
|
+
<div className="grid grid-cols-1 @sm:grid-cols-2 gap-3">
|
|
27
|
+
<div>
|
|
28
|
+
<label className="block text-[11.5px] text-text-2 mb-1.5 font-medium">Name</label>
|
|
29
|
+
<input
|
|
30
|
+
type="text"
|
|
31
|
+
placeholder="Your name"
|
|
32
|
+
className="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"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
<div>
|
|
36
|
+
<label className="block text-[11.5px] text-text-2 mb-1.5 font-medium">Email</label>
|
|
37
|
+
<input
|
|
38
|
+
type="email"
|
|
39
|
+
placeholder="you@example.com"
|
|
40
|
+
className="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"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div>
|
|
45
|
+
<label className="block text-[11.5px] text-text-2 mb-1.5 font-medium">Message</label>
|
|
46
|
+
<textarea
|
|
47
|
+
rows={4}
|
|
48
|
+
placeholder="How can we help?"
|
|
49
|
+
className="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"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => toast("Message sent! We'll be in touch soon.")}
|
|
54
|
+
className="w-full py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all hover:accent-glow-lg flex items-center justify-center gap-2"
|
|
55
|
+
>
|
|
56
|
+
<Send size={14} />
|
|
57
|
+
Send Message
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { renderMarkdown } from '@/lib/markdown'
|
|
2
|
+
import type { BlockConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
export function ContentBlock({ block }: { block: BlockConfig }) {
|
|
5
|
+
const { variant, props } = block
|
|
6
|
+
const body = (props.body as string) || ''
|
|
7
|
+
const rendered = renderMarkdown(body)
|
|
8
|
+
|
|
9
|
+
if (variant === 'columns') {
|
|
10
|
+
// Split rendered nodes roughly in half
|
|
11
|
+
const mid = Math.ceil(rendered.length / 2)
|
|
12
|
+
return (
|
|
13
|
+
<div className="px-6 py-12 @lg:px-16 @lg:py-16">
|
|
14
|
+
<div className="reveal-fade-up reveal-d1 grid grid-cols-1 @lg:grid-cols-2 gap-8 @lg:gap-12">
|
|
15
|
+
<div>{rendered.slice(0, mid)}</div>
|
|
16
|
+
<div>{rendered.slice(mid)}</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (variant === 'highlight') {
|
|
23
|
+
return (
|
|
24
|
+
<div className="px-6 py-12 @lg:px-16 @lg:py-16">
|
|
25
|
+
<div className="reveal-slide-right reveal-d1 border-l-2 border-green pl-6 @lg:pl-8 bg-green-glow2 rounded-r-lg py-6 px-4">
|
|
26
|
+
{rendered}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// prose (default)
|
|
33
|
+
return (
|
|
34
|
+
<div className="reveal-fade-up reveal-d1 px-6 py-12 @lg:px-16 @lg:py-16 max-w-3xl mx-auto">
|
|
35
|
+
{rendered}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { BlockConfig } from '../types'
|
|
2
|
+
import { ArrowRight } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
interface CtaProps {
|
|
5
|
+
headline: string
|
|
6
|
+
subheadline?: string
|
|
7
|
+
buttonText: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function CtaSimple({ props }: { props: CtaProps }) {
|
|
11
|
+
return (
|
|
12
|
+
<section className="px-6 @md:px-10 py-16 @md:py-20 text-center relative overflow-hidden">
|
|
13
|
+
{/* Background */}
|
|
14
|
+
<div className="absolute inset-0 bg-gradient-to-b from-green/6 via-green/3 to-transparent pointer-events-none" />
|
|
15
|
+
|
|
16
|
+
<div className="relative z-10">
|
|
17
|
+
<h2 className="reveal-fade-up reveal-d1 text-2xl @md:text-3xl font-bold tracking-tight mb-3">
|
|
18
|
+
{props.headline}
|
|
19
|
+
</h2>
|
|
20
|
+
{props.subheadline && (
|
|
21
|
+
<p className="reveal-fade-up reveal-d2 text-text-2 text-sm mb-6 max-w-md mx-auto">
|
|
22
|
+
{props.subheadline}
|
|
23
|
+
</p>
|
|
24
|
+
)}
|
|
25
|
+
<div className="reveal-fade-up reveal-d3">
|
|
26
|
+
<button className="px-8 py-3 rounded-lg bg-green text-black text-sm font-semibold hover:bg-green-dim transition-all hover:accent-glow-xl inline-flex items-center gap-2">
|
|
27
|
+
{props.buttonText}
|
|
28
|
+
<ArrowRight size={16} />
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</section>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CtaSplit({ props }: { props: CtaProps }) {
|
|
37
|
+
return (
|
|
38
|
+
<section className="px-6 @md:px-10 py-12 @md:py-16">
|
|
39
|
+
<div className="reveal-scale reveal-d1 flex flex-col @lg:flex-row items-center justify-between gap-6 p-8 rounded-xl bg-bg-2 border border-border-default relative overflow-hidden">
|
|
40
|
+
<div className="absolute inset-0 bg-gradient-to-r from-green/5 to-transparent pointer-events-none" />
|
|
41
|
+
<div className="relative z-10">
|
|
42
|
+
<h2 className="text-xl @md:text-2xl font-bold tracking-tight mb-1">
|
|
43
|
+
{props.headline}
|
|
44
|
+
</h2>
|
|
45
|
+
{props.subheadline && (
|
|
46
|
+
<p className="text-text-2 text-sm">{props.subheadline}</p>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
<button className="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 flex items-center gap-2">
|
|
50
|
+
{props.buttonText}
|
|
51
|
+
<ArrowRight size={16} />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function CtaBlock({ block }: { block: BlockConfig }) {
|
|
59
|
+
const props = block.props as unknown as CtaProps
|
|
60
|
+
|
|
61
|
+
switch (block.variant) {
|
|
62
|
+
case 'split':
|
|
63
|
+
return <CtaSplit props={props} />
|
|
64
|
+
default:
|
|
65
|
+
return <CtaSimple props={props} />
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BlockConfig } from '../types'
|
|
2
|
+
|
|
3
|
+
export function DividerBlock({ block }: { block: BlockConfig }) {
|
|
4
|
+
const { variant } = block
|
|
5
|
+
const height = Number(block.props.height) || 60
|
|
6
|
+
const width = (block.props.width as string) || 'full'
|
|
7
|
+
|
|
8
|
+
const widthClass = width === 'narrow' ? 'max-w-32' : width === 'centered' ? 'max-w-xs' : 'w-full'
|
|
9
|
+
|
|
10
|
+
if (variant === 'space') {
|
|
11
|
+
return <div style={{ height: `${height}px` }} />
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (variant === 'dots') {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex items-center justify-center gap-2 py-8" style={{ minHeight: `${height}px` }}>
|
|
17
|
+
{[0, 1, 2].map((i) => (
|
|
18
|
+
<span key={i} className="w-1.5 h-1.5 rounded-full bg-text-3" />
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// line (default)
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex items-center justify-center" style={{ minHeight: `${height}px` }}>
|
|
27
|
+
<div className={`${widthClass} mx-auto border-t border-border-default`} />
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ChevronDown } from 'lucide-react'
|
|
3
|
+
import type { BlockConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
interface FaqItem {
|
|
6
|
+
question: string
|
|
7
|
+
answer: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FaqProps {
|
|
11
|
+
title?: string
|
|
12
|
+
subtitle?: string
|
|
13
|
+
items?: FaqItem[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const defaultFaqs: FaqItem[] = [
|
|
17
|
+
{ question: 'What is OpenPage?', answer: '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.' },
|
|
18
|
+
{ question: 'How does the JSON config work?', answer: '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.' },
|
|
19
|
+
{ question: 'Can I use my own components?', answer: 'Yes! OpenPage supports custom components. You can build your own blocks following our component schema and register them in the block registry.' },
|
|
20
|
+
{ question: 'Is it free to use?', answer: '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.' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function AccordionItem({ item, isOpen, onToggle }: { item: FaqItem; isOpen: boolean; onToggle: () => void }) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="border-b border-border-subtle">
|
|
26
|
+
<button
|
|
27
|
+
onClick={onToggle}
|
|
28
|
+
className="w-full flex items-center justify-between py-4 text-left group"
|
|
29
|
+
>
|
|
30
|
+
<span className="text-[13.5px] font-medium text-text-0 group-hover:text-green transition-colors">
|
|
31
|
+
{item.question}
|
|
32
|
+
</span>
|
|
33
|
+
<ChevronDown
|
|
34
|
+
size={16}
|
|
35
|
+
className={`text-text-3 shrink-0 ml-4 transition-transform duration-200 ${
|
|
36
|
+
isOpen ? 'rotate-180 text-green' : ''
|
|
37
|
+
}`}
|
|
38
|
+
/>
|
|
39
|
+
</button>
|
|
40
|
+
<div
|
|
41
|
+
className={`overflow-hidden transition-all duration-200 ${
|
|
42
|
+
isOpen ? 'max-h-[500px] pb-4' : 'max-h-0'
|
|
43
|
+
}`}
|
|
44
|
+
>
|
|
45
|
+
<p className="text-[12.5px] text-text-2 leading-relaxed pr-8">
|
|
46
|
+
{item.answer}
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function FaqBlock({ block }: { block: BlockConfig }) {
|
|
54
|
+
const props = block.props as unknown as FaqProps
|
|
55
|
+
const items = props.items || defaultFaqs
|
|
56
|
+
const [openIndex, setOpenIndex] = useState<number | null>(0)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<section className="px-6 @md:px-10 py-16 @md:py-20">
|
|
60
|
+
<div className="reveal-fade-up reveal-d1 text-center mb-10">
|
|
61
|
+
<h2 className="text-2xl @md:text-3xl font-bold tracking-tight mb-2">
|
|
62
|
+
{props.title || 'Frequently Asked Questions'}
|
|
63
|
+
</h2>
|
|
64
|
+
{props.subtitle && (
|
|
65
|
+
<p className="text-text-2 text-sm max-w-lg mx-auto">{props.subtitle}</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="max-w-2xl mx-auto">
|
|
70
|
+
{items.map((item, i) => (
|
|
71
|
+
<div key={i} className={`reveal-fade-up reveal-d${Math.min(i + 2, 8)}`}>
|
|
72
|
+
<AccordionItem
|
|
73
|
+
item={item}
|
|
74
|
+
isOpen={openIndex === i}
|
|
75
|
+
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
)
|
|
82
|
+
}
|