simple-module-pagebuilder 0.0.1__py3-none-any.whl

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 (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,144 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { EyebrowSplitSection } from './_internal/eyebrow-split-section';
3
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
4
+
5
+ export type DefinitionSection = {
6
+ subheading: string;
7
+ body: string;
8
+ image1: string;
9
+ image1Alt: string;
10
+ image2: string;
11
+ image2Alt: string;
12
+ };
13
+
14
+ export type DefinitionItem = {
15
+ title: string;
16
+ sections: DefinitionSection[];
17
+ };
18
+
19
+ export type DefinitionListWidgetProps = {
20
+ eyebrow: string;
21
+ heading: string;
22
+ items: DefinitionItem[];
23
+ };
24
+
25
+ function SectionImages({ section }: { section: DefinitionSection }) {
26
+ const imgs = [
27
+ section.image1 ? { src: section.image1, alt: section.image1Alt } : null,
28
+ section.image2 ? { src: section.image2, alt: section.image2Alt } : null,
29
+ ].filter((x): x is { src: string; alt: string } => x !== null);
30
+ if (imgs.length === 0) return null;
31
+ return (
32
+ <div className={imgs.length === 2 ? 'grid grid-cols-2 gap-4' : ''}>
33
+ {imgs.map((img, i) => (
34
+ <img
35
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
36
+ key={i}
37
+ src={img.src}
38
+ alt={img.alt}
39
+ loading="lazy"
40
+ className="aspect-[3/2] w-full rounded-lg object-cover"
41
+ />
42
+ ))}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export const DefinitionListWidget: ComponentConfig<DefinitionListWidgetProps> = {
48
+ label: 'Definition list (rich accordion)',
49
+ fields: {
50
+ eyebrow: { type: 'text', label: 'Eyebrow' },
51
+ heading: { type: 'text', label: 'Heading' },
52
+ items: {
53
+ type: 'array',
54
+ label: 'Definitions',
55
+ arrayFields: {
56
+ title: { type: 'text', label: 'Title' },
57
+ sections: {
58
+ type: 'array',
59
+ label: 'Sections',
60
+ arrayFields: {
61
+ subheading: { type: 'text', label: 'Sub-heading' },
62
+ body: { type: 'textarea', label: 'Body' },
63
+ image1: { type: 'text', label: 'Image 1 URL' },
64
+ image1Alt: { type: 'text', label: 'Image 1 alt' },
65
+ image2: { type: 'text', label: 'Image 2 URL (optional)' },
66
+ image2Alt: { type: 'text', label: 'Image 2 alt' },
67
+ },
68
+ defaultItemProps: {
69
+ subheading: '',
70
+ body: '',
71
+ image1: '',
72
+ image1Alt: '',
73
+ image2: '',
74
+ image2Alt: '',
75
+ },
76
+ },
77
+ },
78
+ defaultItemProps: { title: 'New definition', sections: [] },
79
+ min: 1,
80
+ max: 20,
81
+ },
82
+ },
83
+ defaultProps: {
84
+ eyebrow: 'Definitions',
85
+ heading: 'Definitions',
86
+ items: [{ title: 'Definition', sections: [] }],
87
+ },
88
+ render: ({ eyebrow, heading, items }) => (
89
+ <EyebrowSplitSection eyebrow={eyebrow} heading={heading}>
90
+ <div className="border-t border-[#1a353e]/15">
91
+ {items?.map((item, idx) => (
92
+ <details
93
+ // Index key: items have no stable id, and keying by title would
94
+ // collide on duplicate titles (defaultItemProps.title is fixed).
95
+ // Reordering in the editor can rebind open-state to a slot — an
96
+ // accepted editor-only edge until items carry an id.
97
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
98
+ key={idx}
99
+ className="group border-b border-[#1a353e]/15"
100
+ // `open` set at mount only (widget has no state → no re-render
101
+ // re-asserts it), so the first row starts open but stays toggleable.
102
+ {...(idx === 0 ? { open: true } : {})}
103
+ >
104
+ <summary
105
+ className="flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold"
106
+ style={{ color: 'var(--pb-heading-color)' }}
107
+ >
108
+ {/* Inside <summary>: emphasis only — a nested anchor here
109
+ would be invalid HTML and hijack the toggle. */}
110
+ {renderRichText(item.title || '', { allowLinks: false })}
111
+ <span
112
+ aria-hidden="true"
113
+ className="shrink-0 text-2xl font-light leading-none text-[#1a353e]"
114
+ >
115
+ <span className="group-open:hidden">+</span>
116
+ <span className="hidden group-open:inline">−</span>
117
+ </span>
118
+ </summary>
119
+ <div className="flex flex-col gap-5 pb-6">
120
+ {item.sections?.map((section, si) => (
121
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
122
+ <div key={si} className="flex flex-col gap-3">
123
+ {section.subheading && (
124
+ <p className="font-semibold" style={{ color: 'var(--pb-heading-color)' }}>
125
+ {renderRichText(section.subheading)}
126
+ </p>
127
+ )}
128
+ {section.body && (
129
+ <RichTextBlock
130
+ text={section.body}
131
+ className="leading-[var(--pb-body-leading,1.625)]"
132
+ style={{ color: 'var(--pb-body-color)' }}
133
+ />
134
+ )}
135
+ <SectionImages section={section} />
136
+ </div>
137
+ ))}
138
+ </div>
139
+ </details>
140
+ ))}
141
+ </div>
142
+ </EyebrowSplitSection>
143
+ ),
144
+ };
@@ -0,0 +1,56 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { cn } from '../../utils/widgetUtils';
3
+
4
+ export type DividerWidgetProps = {
5
+ thickness: 'thin' | 'medium' | 'thick';
6
+ style: 'solid' | 'dashed' | 'dotted';
7
+ color: string;
8
+ };
9
+
10
+ const THICKNESS_CLASS: Record<DividerWidgetProps['thickness'], string> = {
11
+ thin: 'border-t',
12
+ medium: 'border-t-2',
13
+ thick: 'border-t-4',
14
+ };
15
+
16
+ const STYLE_CLASS: Record<DividerWidgetProps['style'], string> = {
17
+ solid: 'border-solid',
18
+ dashed: 'border-dashed',
19
+ dotted: 'border-dotted',
20
+ };
21
+
22
+ export const DividerWidget: ComponentConfig<DividerWidgetProps> = {
23
+ label: 'Divider',
24
+ fields: {
25
+ thickness: {
26
+ type: 'select',
27
+ label: 'Thickness',
28
+ options: [
29
+ { label: 'Thin', value: 'thin' },
30
+ { label: 'Medium', value: 'medium' },
31
+ { label: 'Thick', value: 'thick' },
32
+ ],
33
+ },
34
+ style: {
35
+ type: 'select',
36
+ label: 'Style',
37
+ options: [
38
+ { label: 'Solid', value: 'solid' },
39
+ { label: 'Dashed', value: 'dashed' },
40
+ { label: 'Dotted', value: 'dotted' },
41
+ ],
42
+ },
43
+ color: { type: 'text', label: 'Color (CSS value)' },
44
+ },
45
+ defaultProps: {
46
+ thickness: 'thin',
47
+ style: 'solid',
48
+ color: '#e5e7eb',
49
+ },
50
+ render: ({ thickness, style, color }) => (
51
+ <hr
52
+ className={cn('my-6', THICKNESS_CLASS[thickness], STYLE_CLASS[style])}
53
+ style={{ borderColor: color }}
54
+ />
55
+ ),
56
+ };
@@ -0,0 +1,56 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import type { CSSProperties } from 'react';
3
+ import { AccentText } from './_internal/accent-text';
4
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
5
+
6
+ export type EyebrowSectionWidgetProps = {
7
+ eyebrow: string;
8
+ heading: string;
9
+ body: string;
10
+ };
11
+
12
+ export const EyebrowSectionWidget: ComponentConfig<EyebrowSectionWidgetProps> = {
13
+ label: 'Eyebrow + heading + body',
14
+ fields: {
15
+ eyebrow: { type: 'text', label: 'Eyebrow' },
16
+ heading: { type: 'text', label: 'Heading' },
17
+ body: { type: 'textarea', label: 'Body' },
18
+ },
19
+ defaultProps: {
20
+ eyebrow: 'Our story',
21
+ heading: 'How we got here',
22
+ body: 'Body text describing the section in more detail.',
23
+ },
24
+ render: ({ eyebrow, heading, body }) => (
25
+ <section className="container mx-auto px-4 py-12 max-w-3xl">
26
+ {eyebrow && (
27
+ <p className="text-sm font-semibold uppercase tracking-wider text-primary-700">
28
+ {renderRichText(eyebrow)}
29
+ </p>
30
+ )}
31
+ {/* AccentText, not bare renderRichText: this is a display heading on
32
+ `--pb-display-weight`, where a plain <strong>'s relative `bolder`
33
+ can compute to no visible change. */}
34
+ {heading && (
35
+ <h2
36
+ className="mt-2 text-2xl sm:text-3xl lg:text-4xl"
37
+ style={{
38
+ fontWeight: 'var(--pb-display-weight)' as CSSProperties['fontWeight'],
39
+ letterSpacing: 'var(--pb-display-tracking)',
40
+ fontFamily: 'var(--pb-display-font)',
41
+ color: 'var(--pb-heading-color)',
42
+ }}
43
+ >
44
+ <AccentText text={heading} />
45
+ </h2>
46
+ )}
47
+ {body && (
48
+ <RichTextBlock
49
+ text={body}
50
+ className="mt-4 text-lg leading-relaxed"
51
+ style={{ color: 'var(--pb-body-color)' }}
52
+ />
53
+ )}
54
+ </section>
55
+ ),
56
+ };
@@ -0,0 +1,268 @@
1
+ /** FAQ types, styles and render — split from faq-widget.tsx, which keeps the
2
+ * field definitions, so both stay under the 300-line cap. */
3
+
4
+ import type { CSSProperties } from 'react';
5
+
6
+ import { parseMarkdownBlocks } from '../../utils/markdown';
7
+ import { cn } from '../../utils/widgetUtils';
8
+ import { AccentText } from './_internal/accent-text';
9
+ import { DisclosureChevron } from './_internal/disclosure-chevron';
10
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
11
+ import { eyebrowTextStyle } from './_shared';
12
+
13
+ export type FaqItem = {
14
+ question: string;
15
+ answer: string;
16
+ };
17
+
18
+ export type FaqWidgetProps = {
19
+ eyebrow: string;
20
+ title: string;
21
+ /** Optional paragraph under the title (2-column layout only). */
22
+ lead?: string;
23
+ variant: 'cards' | 'divider';
24
+ /** "muted" wraps the 2-column layout in a soft rounded card (Mowing FAQs). */
25
+ surface?: 'default' | 'muted';
26
+ /**
27
+ * Optional CSS color for the 2-column band background (e.g. a tenant's
28
+ * program color). When set, the band renders with white text and rules.
29
+ */
30
+ surfaceColor?: string;
31
+ items: FaqItem[];
32
+ };
33
+
34
+ const DISPLAY_STYLE: CSSProperties = {
35
+ fontWeight: 'var(--pb-display-weight)' as CSSProperties['fontWeight'],
36
+ letterSpacing: 'var(--pb-display-tracking)',
37
+ fontFamily: 'var(--pb-display-font)',
38
+ color: 'var(--pb-heading-color)',
39
+ };
40
+
41
+ // Section heading size: reads `--pb-heading-md` (Mowing → 40px to match the
42
+ // Figma) and falls back to the prior responsive scale (~30→48px) so tenants
43
+ // that don't set the token (GCA, Recodo) render unchanged.
44
+ const HEADING_STYLE: CSSProperties = {
45
+ ...DISPLAY_STYLE,
46
+ fontSize: 'var(--pb-heading-md, clamp(1.875rem, 1.3rem + 2.2vw, 3rem))',
47
+ };
48
+
49
+ /** An answer is light markdown: links, `- ` bullets and `**bold**`. */
50
+ function Answer({ text, inverse }: { text: string; inverse?: boolean }) {
51
+ return (
52
+ <div
53
+ className="pb-5 leading-[var(--pb-body-leading,1.625)]"
54
+ style={
55
+ inverse
56
+ ? { color: 'var(--pb-surface-contrast, #ffffff)', opacity: 0.85 }
57
+ : { color: 'var(--pb-body-color)' }
58
+ }
59
+ >
60
+ {parseMarkdownBlocks(text || '').map((block, i) =>
61
+ block.type === 'ul' ? (
62
+ // biome-ignore lint/suspicious/noArrayIndexKey: blocks are positional slices of one string, re-derived each render
63
+ <ul key={i} className="list-disc pl-5 [&:not(:first-child)]:mt-3">
64
+ {block.items.map((li, j) => (
65
+ // biome-ignore lint/suspicious/noArrayIndexKey: list items are positional slices too
66
+ <li key={j}>{renderRichText(li)}</li>
67
+ ))}
68
+ </ul>
69
+ ) : (
70
+ // biome-ignore lint/suspicious/noArrayIndexKey: blocks are positional slices of one string, re-derived each render
71
+ <p key={i} className="[&:not(:first-child)]:mt-3">
72
+ {renderRichText(block.text)}
73
+ </p>
74
+ ),
75
+ )}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ // Divider-row accordion (thin top/bottom rules, +/- toggle) — the GCA style,
81
+ // shared by the 2-column (our-process) and single-column (FAQs / statements)
82
+ // layouts.
83
+ function DividerItems({ items, inverse = false }: { items: FaqItem[]; inverse?: boolean }) {
84
+ return (
85
+ <div
86
+ className={cn(
87
+ 'border-t-[length:var(--pb-accordion-top-rule,1px)]',
88
+ inverse ? 'border-white/30' : 'border-[#1a353e]/15',
89
+ )}
90
+ >
91
+ {items?.map((item, idx) => (
92
+ // Keyed on the question so reordering in the editor carries each row's
93
+ // open/closed state with it; the index is only a fallback for a blank one.
94
+ <details
95
+ key={item.question || `faq-${idx}`}
96
+ className={cn('group border-b', inverse ? 'border-white/30' : 'border-[#1a353e]/15')}
97
+ >
98
+ <summary
99
+ className="flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold"
100
+ style={{
101
+ color: inverse ? 'var(--pb-surface-contrast, #ffffff)' : 'var(--pb-heading-color)',
102
+ }}
103
+ >
104
+ {/* Inside <summary>: emphasis only — a nested anchor here would
105
+ be invalid HTML and hijack the toggle. */}
106
+ {renderRichText(item.question || '', { allowLinks: false })}
107
+ <span
108
+ aria-hidden="true"
109
+ className={cn(
110
+ 'shrink-0 text-2xl font-light leading-none',
111
+ !inverse && 'text-[#1a353e]',
112
+ )}
113
+ style={inverse ? { color: 'var(--pb-surface-contrast, #ffffff)' } : undefined}
114
+ >
115
+ <span className="group-open:hidden">+</span>
116
+ <span className="hidden group-open:inline">−</span>
117
+ </span>
118
+ </summary>
119
+ <Answer text={item.answer} inverse={inverse} />
120
+ </details>
121
+ ))}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /** Figma 2-column layout: eyebrow left, heading + divider accordion right. */
127
+ function SplitFaq({
128
+ eyebrow,
129
+ title,
130
+ lead,
131
+ surface,
132
+ surfaceColor,
133
+ items,
134
+ }: Required<Pick<FaqWidgetProps, 'eyebrow' | 'title' | 'items'>> &
135
+ Pick<FaqWidgetProps, 'lead' | 'surface' | 'surfaceColor'>) {
136
+ const muted = surface === 'muted';
137
+ const inverse = !!surfaceColor;
138
+ return (
139
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
140
+ <div
141
+ className={cn(
142
+ 'grid gap-8 lg:grid-cols-[var(--pb-split-cols,1fr_2fr)] lg:gap-[var(--pb-split-gap,3rem)]',
143
+ muted &&
144
+ !inverse &&
145
+ 'rounded-[12px] bg-[var(--pb-surface-muted,#f3f3f4)] px-8 py-12 lg:px-0 lg:py-20',
146
+ inverse && 'rounded-[12px] px-8 py-12 lg:px-0 lg:py-20',
147
+ )}
148
+ style={inverse ? { backgroundColor: surfaceColor } : undefined}
149
+ >
150
+ <p
151
+ className={cn((muted || inverse) && 'lg:pl-12')}
152
+ style={{
153
+ // Eyebrow size/weight/case/tracking are theme-driven; the
154
+ // fallbacks keep today's look for tenants that don't set them.
155
+ ...eyebrowTextStyle('1.125rem', '400'),
156
+ color: inverse
157
+ ? 'var(--pb-surface-contrast, #ffffff)'
158
+ : 'var(--pb-heading-color,#161728)',
159
+ }}
160
+ >
161
+ {renderRichText(eyebrow)}
162
+ </p>
163
+ <div className="max-w-[var(--pb-split-content-max,none)]">
164
+ <div className="max-w-[var(--pb-faq-intro-max,none)]">
165
+ {/* AccentText, not bare renderRichText: these are display headings
166
+ on `--pb-display-weight`, where a plain <strong>'s relative
167
+ `bolder` can compute to no visible change. */}
168
+ {title && (
169
+ <h2
170
+ className="mb-8 leading-tight"
171
+ style={
172
+ inverse
173
+ ? { ...HEADING_STYLE, color: 'var(--pb-surface-contrast, #ffffff)' }
174
+ : HEADING_STYLE
175
+ }
176
+ >
177
+ <AccentText text={title} />
178
+ </h2>
179
+ )}
180
+ {lead && (
181
+ <RichTextBlock
182
+ text={lead}
183
+ className="-mt-4 mb-8 text-lg leading-[var(--pb-body-leading,1.625)]"
184
+ style={
185
+ inverse
186
+ ? { color: 'var(--pb-surface-contrast, #ffffff)', opacity: 0.85 }
187
+ : { color: 'var(--pb-body-color)' }
188
+ }
189
+ />
190
+ )}
191
+ </div>
192
+ <DividerItems items={items} inverse={inverse} />
193
+ </div>
194
+ </div>
195
+ </section>
196
+ );
197
+ }
198
+
199
+ export function FaqRender({
200
+ eyebrow,
201
+ title,
202
+ lead = '',
203
+ variant = 'cards',
204
+ surface = 'default',
205
+ surfaceColor = '',
206
+ items,
207
+ }: FaqWidgetProps) {
208
+ // The 2-column layout is opt-in via `eyebrow`.
209
+ if (eyebrow) {
210
+ return (
211
+ <SplitFaq
212
+ eyebrow={eyebrow}
213
+ title={title}
214
+ lead={lead}
215
+ surface={surface}
216
+ surfaceColor={surfaceColor}
217
+ items={items}
218
+ />
219
+ );
220
+ }
221
+
222
+ // Single narrow-column divider accordion (FAQs / statements pages — the page
223
+ // hero already supplies the title, so this is usually just the list).
224
+ if (variant === 'divider') {
225
+ return (
226
+ <section className="container mx-auto max-w-3xl px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
227
+ {title && (
228
+ <h2 className="mb-8 leading-tight" style={HEADING_STYLE}>
229
+ <AccentText text={title} />
230
+ </h2>
231
+ )}
232
+ <DividerItems items={items} />
233
+ </section>
234
+ );
235
+ }
236
+
237
+ return (
238
+ <div className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] max-w-3xl">
239
+ {title && (
240
+ <h2 className="text-3xl sm:text-4xl lg:text-5xl text-center mb-10" style={DISPLAY_STYLE}>
241
+ <AccentText text={title} />
242
+ </h2>
243
+ )}
244
+ <div className="space-y-3">
245
+ {items?.map((item, idx) => (
246
+ <details
247
+ key={item.question || `faq-card-${idx}`}
248
+ className="group rounded-lg border bg-white dark:bg-gray-900 p-4"
249
+ >
250
+ <summary
251
+ className="cursor-pointer flex items-center justify-between text-lg font-semibold list-none"
252
+ style={{ color: 'var(--pb-heading-color)' }}
253
+ >
254
+ {renderRichText(item.question || '', { allowLinks: false })}
255
+ <DisclosureChevron />
256
+ </summary>
257
+ {/* Same light markdown as the divider variant's answers. */}
258
+ <RichTextBlock
259
+ text={item.answer}
260
+ className="mt-3 leading-[var(--pb-body-leading,1.625)]"
261
+ style={{ color: 'var(--pb-body-color)' }}
262
+ />
263
+ </details>
264
+ ))}
265
+ </div>
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,64 @@
1
+ /** FAQ widget: field definitions and config assembly.
2
+ * Types, styles and render live in faq-render.tsx. */
3
+
4
+ import type { ComponentConfig } from '@puckeditor/core';
5
+
6
+ import { FaqRender, type FaqWidgetProps } from './faq-render';
7
+
8
+ export type { FaqItem, FaqWidgetProps } from './faq-render';
9
+
10
+ export const FaqWidget: ComponentConfig<FaqWidgetProps> = {
11
+ label: 'FAQ',
12
+ fields: {
13
+ eyebrow: { type: 'text', label: 'Eyebrow (enables 2-column layout)' },
14
+ title: { type: 'text', label: 'Title' },
15
+ lead: { type: 'textarea', label: 'Lead paragraph (2-column layout)' },
16
+ variant: {
17
+ type: 'select',
18
+ label: 'Style',
19
+ options: [
20
+ { label: 'Cards', value: 'cards' },
21
+ { label: 'Divider rows', value: 'divider' },
22
+ ],
23
+ },
24
+ surface: {
25
+ type: 'select',
26
+ label: 'Surface (2-column layout)',
27
+ options: [
28
+ { label: 'Default', value: 'default' },
29
+ { label: 'Muted (soft card)', value: 'muted' },
30
+ ],
31
+ },
32
+ surfaceColor: {
33
+ type: 'text',
34
+ label: 'Surface color (CSS, white text — optional)',
35
+ },
36
+ items: {
37
+ type: 'array',
38
+ label: 'Questions',
39
+ arrayFields: {
40
+ question: { type: 'text', label: 'Question' },
41
+ answer: { type: 'textarea', label: 'Answer' },
42
+ },
43
+ defaultItemProps: {
44
+ question: 'Your question?',
45
+ answer: 'The answer goes here.',
46
+ },
47
+ min: 1,
48
+ max: 30,
49
+ },
50
+ },
51
+ defaultProps: {
52
+ eyebrow: '',
53
+ title: 'Frequently asked questions',
54
+ lead: '',
55
+ variant: 'cards',
56
+ surface: 'default',
57
+ surfaceColor: '',
58
+ items: [
59
+ { question: 'How does this work?', answer: 'It just does.' },
60
+ { question: 'Is it free?', answer: "There's a free plan." },
61
+ ],
62
+ },
63
+ render: (props) => <FaqRender {...props} />,
64
+ };
@@ -0,0 +1,98 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import type { CSSProperties } from 'react';
3
+ import { createImageField, mediaLibraryAdapter } from '../../fields';
4
+ import { AccentText } from './_internal/accent-text';
5
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
6
+
7
+ export type FeatureCardWidgetProps = {
8
+ eyebrow: string;
9
+ title: string;
10
+ description: string;
11
+ imageUrl: string;
12
+ imageAlt: string;
13
+ linkLabel: string;
14
+ linkHref: string;
15
+ };
16
+
17
+ export const FeatureCardWidget: ComponentConfig<FeatureCardWidgetProps> = {
18
+ label: 'Feature card (image + text)',
19
+ fields: {
20
+ eyebrow: { type: 'text', label: 'Eyebrow tag' },
21
+ title: { type: 'text', label: 'Title' },
22
+ description: { type: 'textarea', label: 'Description' },
23
+ imageUrl: createImageField(mediaLibraryAdapter, 'Image URL'),
24
+ imageAlt: { type: 'text', label: 'Image alt text' },
25
+ linkLabel: { type: 'text', label: 'Link label' },
26
+ linkHref: { type: 'text', label: 'Link URL' },
27
+ },
28
+ defaultProps: {
29
+ eyebrow: 'Featured',
30
+ title: 'A great looking card',
31
+ description: 'Pair an image with text and a clear call to action.',
32
+ imageUrl: '',
33
+ imageAlt: '',
34
+ linkLabel: 'Read more',
35
+ linkHref: '#',
36
+ },
37
+ render: ({ eyebrow, title, description, imageUrl, imageAlt, linkLabel, linkHref }) => (
38
+ <article className="container mx-auto px-4 py-16 lg:py-20 sm:max-w-xl md:max-w-full lg:max-w-screen-xl md:px-24 lg:px-8">
39
+ <div className="flex flex-col max-w-screen-lg overflow-hidden border rounded shadow-sm lg:flex-row sm:mx-auto">
40
+ <div className="relative lg:w-1/2">
41
+ {imageUrl ? (
42
+ <img
43
+ src={imageUrl}
44
+ alt={imageAlt || title}
45
+ loading="lazy"
46
+ className="object-cover w-full lg:absolute h-80 lg:h-full"
47
+ />
48
+ ) : (
49
+ <div className="w-full lg:absolute h-80 lg:h-full bg-gradient-to-br from-gray-200 to-gray-300" />
50
+ )}
51
+ </div>
52
+ <div className="flex flex-col justify-center p-8 lg:p-16 lg:pl-10 lg:w-1/2">
53
+ {eyebrow && (
54
+ <p className="inline-block px-3 py-px mb-4 text-xs font-semibold tracking-wider uppercase rounded-full bg-teal-100 text-teal-800 w-fit">
55
+ {renderRichText(eyebrow)}
56
+ </p>
57
+ )}
58
+ {/* AccentText, not bare renderRichText: this is a display heading on
59
+ `--pb-display-weight`, where a plain <strong>'s relative `bolder`
60
+ can compute to no visible change. */}
61
+ {title && (
62
+ <h3
63
+ className="mb-3 text-3xl leading-none sm:text-4xl"
64
+ style={{
65
+ fontWeight: 'var(--pb-display-weight)' as CSSProperties['fontWeight'],
66
+ letterSpacing: 'var(--pb-display-tracking)',
67
+ fontFamily: 'var(--pb-display-font)',
68
+ color: 'var(--pb-heading-color)',
69
+ }}
70
+ >
71
+ <AccentText text={title} />
72
+ </h3>
73
+ )}
74
+ {description && <RichTextBlock text={description} className="mb-5" />}
75
+ {linkLabel && (
76
+ <div className="flex items-center">
77
+ <a
78
+ href={linkHref || '#'}
79
+ className="inline-flex items-center font-semibold transition-colors duration-200 text-primary-700 hover:text-primary-900"
80
+ >
81
+ {renderRichText(linkLabel, { allowLinks: false })}
82
+ <svg
83
+ className="inline-block w-3 ml-2"
84
+ fill="currentColor"
85
+ viewBox="0 0 12 12"
86
+ aria-hidden="true"
87
+ >
88
+ <title>Arrow right</title>
89
+ <path d="M9.707,5.293l-5-5A1,1,0,0,0,3.293,1.707L7.586,6,3.293,10.293a1,1,0,1,0,1.414,1.414l5-5A1,1,0,0,0,9.707,5.293Z" />
90
+ </svg>
91
+ </a>
92
+ </div>
93
+ )}
94
+ </div>
95
+ </div>
96
+ </article>
97
+ ),
98
+ };