@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +4 -0
  3. package/README.md +82 -0
  4. package/dist/index.d.ts +401 -0
  5. package/dist/index.js +5389 -0
  6. package/package.json +66 -0
  7. package/src/blocks/BlockWrapper.tsx +149 -0
  8. package/src/blocks/banner/BannerBlock.tsx +38 -0
  9. package/src/blocks/contact/ContactBlock.tsx +63 -0
  10. package/src/blocks/content/ContentBlock.tsx +38 -0
  11. package/src/blocks/cta/CtaBlock.tsx +67 -0
  12. package/src/blocks/divider/DividerBlock.tsx +30 -0
  13. package/src/blocks/faq/FaqBlock.tsx +82 -0
  14. package/src/blocks/features/FeaturesBlock.tsx +170 -0
  15. package/src/blocks/footer/FooterBlock.tsx +136 -0
  16. package/src/blocks/gallery/GalleryBlock.tsx +67 -0
  17. package/src/blocks/hero/HeroBlock.tsx +163 -0
  18. package/src/blocks/image/ImageBlock.tsx +78 -0
  19. package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
  20. package/src/blocks/navbar/NavbarBlock.tsx +102 -0
  21. package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
  22. package/src/blocks/pricing/PricingBlock.tsx +173 -0
  23. package/src/blocks/registry.tsx +90 -0
  24. package/src/blocks/stats/StatsBlock.tsx +96 -0
  25. package/src/blocks/team/TeamBlock.tsx +50 -0
  26. package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
  27. package/src/blocks/types.ts +72 -0
  28. package/src/blocks/video/VideoBlock.tsx +58 -0
  29. package/src/editor/AgentPanel.tsx +318 -0
  30. package/src/editor/Canvas.tsx +95 -0
  31. package/src/editor/CanvasEmpty.tsx +40 -0
  32. package/src/editor/CanvasToolbar.tsx +366 -0
  33. package/src/editor/DesignPanel.tsx +224 -0
  34. package/src/editor/EditorLayout.tsx +196 -0
  35. package/src/editor/GenerationOverlay.tsx +201 -0
  36. package/src/editor/JsonDrawer.tsx +139 -0
  37. package/src/editor/LayersPanel.tsx +259 -0
  38. package/src/editor/LeftSidebar.tsx +136 -0
  39. package/src/editor/PropertiesPanel.tsx +564 -0
  40. package/src/editor/RightSidebar.tsx +85 -0
  41. package/src/editor/ShortcutsModal.tsx +126 -0
  42. package/src/editor/VersionHistory.tsx +110 -0
  43. package/src/index.ts +133 -0
  44. package/src/lib/block-metadata.ts +167 -0
  45. package/src/lib/export-html.ts +1424 -0
  46. package/src/lib/generate-site.ts +206 -0
  47. package/src/lib/generation-prompt.ts +64 -0
  48. package/src/lib/markdown.ts +88 -0
  49. package/src/lib/templates.ts +139 -0
  50. package/src/lib/theme-presets.ts +330 -0
  51. package/src/lib/useGoogleFonts.ts +49 -0
  52. package/src/lib/useScrollReveal.ts +28 -0
  53. package/src/settings/GeminiKeyInputField.tsx +82 -0
  54. package/src/store/configStore.ts +344 -0
  55. package/src/store/editorStore.ts +50 -0
  56. 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
+ }