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,63 @@
1
+ import type { ComponentConfig, Slot } from '@puckeditor/core';
2
+ import { cn } from '../../utils/widgetUtils';
3
+
4
+ export type ColumnWidgetProps = {
5
+ width: '1/4' | '1/3' | '1/2' | '2/3' | '3/4' | 'full';
6
+ gap: 'none' | 'sm' | 'md' | 'lg';
7
+ column: Slot;
8
+ };
9
+
10
+ const WIDTH_CLASS: Record<ColumnWidgetProps['width'], string> = {
11
+ '1/4': 'w-full md:w-1/4',
12
+ '1/3': 'w-full md:w-1/3',
13
+ '1/2': 'w-full md:w-1/2',
14
+ '2/3': 'w-full md:w-2/3',
15
+ '3/4': 'w-full md:w-3/4',
16
+ full: 'w-full',
17
+ };
18
+
19
+ const GAP_CLASS: Record<ColumnWidgetProps['gap'], string> = {
20
+ none: 'gap-0',
21
+ sm: 'gap-2',
22
+ md: 'gap-4',
23
+ lg: 'gap-8',
24
+ };
25
+
26
+ export const ColumnWidget: ComponentConfig<ColumnWidgetProps> = {
27
+ label: 'Column',
28
+ fields: {
29
+ width: {
30
+ type: 'select',
31
+ label: 'Width',
32
+ options: [
33
+ { label: 'Quarter', value: '1/4' },
34
+ { label: 'Third', value: '1/3' },
35
+ { label: 'Half', value: '1/2' },
36
+ { label: 'Two thirds', value: '2/3' },
37
+ { label: 'Three quarters', value: '3/4' },
38
+ { label: 'Full', value: 'full' },
39
+ ],
40
+ },
41
+ gap: {
42
+ type: 'select',
43
+ label: 'Gap',
44
+ options: [
45
+ { label: 'None', value: 'none' },
46
+ { label: 'Small', value: 'sm' },
47
+ { label: 'Medium', value: 'md' },
48
+ { label: 'Large', value: 'lg' },
49
+ ],
50
+ },
51
+ column: { type: 'slot' },
52
+ },
53
+ defaultProps: {
54
+ width: '1/2',
55
+ gap: 'md',
56
+ column: [],
57
+ },
58
+ render: ({ width, gap, column: Column }) => (
59
+ <div className={cn('flex flex-col', WIDTH_CLASS[width], GAP_CLASS[gap])}>
60
+ <Column />
61
+ </div>
62
+ ),
63
+ };
@@ -0,0 +1,172 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { User } from 'lucide-react';
3
+ import type { CSSProperties } from 'react';
4
+ import { createImageField, mediaLibraryAdapter } from '../../fields';
5
+ import { AccentText } from './_internal/accent-text';
6
+ import { renderRichText } from './_internal/rich-text';
7
+
8
+ export type ContactCardItem = {
9
+ name: string;
10
+ role: string;
11
+ email: string;
12
+ phone: string;
13
+ imageUrl: string;
14
+ };
15
+
16
+ export type ContactCardsWidgetProps = {
17
+ eyebrow: string;
18
+ title: string;
19
+ variant: 'contact' | 'team';
20
+ items: ContactCardItem[];
21
+ };
22
+
23
+ const DISPLAY_STYLE: CSSProperties = {
24
+ fontWeight: 'var(--pb-display-weight)' as CSSProperties['fontWeight'],
25
+ letterSpacing: 'var(--pb-display-tracking)',
26
+ fontFamily: 'var(--pb-display-font)',
27
+ color: 'var(--pb-heading-color)',
28
+ };
29
+
30
+ export const ContactCardsWidget: ComponentConfig<ContactCardsWidgetProps> = {
31
+ label: 'Contact Cards',
32
+ fields: {
33
+ eyebrow: { type: 'text', label: 'Eyebrow' },
34
+ title: { type: 'text', label: 'Title' },
35
+ variant: {
36
+ type: 'select',
37
+ label: 'Style',
38
+ options: [
39
+ { label: 'Contact (round avatar)', value: 'contact' },
40
+ { label: 'Team (image-top card)', value: 'team' },
41
+ ],
42
+ },
43
+ items: {
44
+ type: 'array',
45
+ label: 'Contacts',
46
+ arrayFields: {
47
+ name: { type: 'text', label: 'Name' },
48
+ role: { type: 'text', label: 'Role' },
49
+ email: { type: 'text', label: 'Email' },
50
+ phone: { type: 'text', label: 'Phone' },
51
+ imageUrl: createImageField(mediaLibraryAdapter, 'Image'),
52
+ },
53
+ defaultItemProps: {
54
+ name: 'Jane Doe',
55
+ role: 'Director',
56
+ email: 'jane@example.com',
57
+ phone: '',
58
+ imageUrl: '',
59
+ },
60
+ min: 1,
61
+ max: 12,
62
+ },
63
+ },
64
+ defaultProps: {
65
+ eyebrow: '',
66
+ title: 'Get in touch',
67
+ variant: 'contact',
68
+ items: [
69
+ {
70
+ name: 'Jane Doe',
71
+ role: 'Director',
72
+ email: 'jane@example.com',
73
+ phone: '',
74
+ imageUrl: '',
75
+ },
76
+ ],
77
+ },
78
+ render: ({ eyebrow, title, variant = 'contact', items }) => {
79
+ const team = variant === 'team';
80
+ return (
81
+ <div className="container mx-auto py-12 px-4 sm:px-6 lg:px-8">
82
+ {(eyebrow || title) && (
83
+ <div className="mb-8 text-center">
84
+ {eyebrow && (
85
+ <p className="mb-2 text-sm font-medium text-[#686873]">{renderRichText(eyebrow)}</p>
86
+ )}
87
+ {/* AccentText, not bare renderRichText: this is a display heading
88
+ on `--pb-display-weight`, where a plain <strong>'s relative
89
+ `bolder` can compute to no visible change. */}
90
+ {title && (
91
+ <h2 className="text-3xl sm:text-4xl" style={DISPLAY_STYLE}>
92
+ <AccentText text={title} />
93
+ </h2>
94
+ )}
95
+ </div>
96
+ )}
97
+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
98
+ {items?.map((item, idx) =>
99
+ team ? (
100
+ // Image-top team card: rectangular media (dark placeholder with a
101
+ // person glyph when no photo), left-aligned name + affiliation.
102
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
103
+ <div key={idx}>
104
+ {item.imageUrl ? (
105
+ <img
106
+ src={item.imageUrl}
107
+ alt={item.name}
108
+ loading="lazy"
109
+ className="aspect-[4/3] w-full rounded-xl object-cover"
110
+ />
111
+ ) : (
112
+ <div className="flex aspect-[4/3] w-full items-center justify-center rounded-xl bg-[#1a353e]">
113
+ <User className="size-10 text-white/35" aria-hidden="true" />
114
+ </div>
115
+ )}
116
+ {item.name && (
117
+ <h3
118
+ className="mt-4 text-base font-semibold"
119
+ style={{ color: 'var(--pb-heading-color)' }}
120
+ >
121
+ {renderRichText(item.name)}
122
+ </h3>
123
+ )}
124
+ {item.role && (
125
+ <p className="mt-1 text-sm text-[#686873]">{renderRichText(item.role)}</p>
126
+ )}
127
+ </div>
128
+ ) : (
129
+ <div
130
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
131
+ key={idx}
132
+ className="rounded-lg border bg-white dark:bg-gray-900 p-6 text-center shadow-sm"
133
+ >
134
+ {item.imageUrl ? (
135
+ <img
136
+ src={item.imageUrl}
137
+ alt={item.name}
138
+ className="size-24 rounded-full mx-auto object-cover"
139
+ />
140
+ ) : (
141
+ <div className="size-24 rounded-full mx-auto bg-gradient-to-br from-gray-200 to-gray-300" />
142
+ )}
143
+ <h3 className="mt-4 text-lg font-semibold">{renderRichText(item.name || '')}</h3>
144
+ {item.role && (
145
+ <p className="text-sm text-gray-500 dark:text-gray-400">
146
+ {renderRichText(item.role)}
147
+ </p>
148
+ )}
149
+ {item.email && (
150
+ <a
151
+ href={`mailto:${item.email}`}
152
+ className="block mt-2 text-primary-700 hover:underline text-sm break-all"
153
+ >
154
+ {item.email}
155
+ </a>
156
+ )}
157
+ {item.phone && (
158
+ <a
159
+ href={`tel:${item.phone}`}
160
+ className="block text-sm text-gray-600 dark:text-gray-300"
161
+ >
162
+ {item.phone}
163
+ </a>
164
+ )}
165
+ </div>
166
+ ),
167
+ )}
168
+ </div>
169
+ </div>
170
+ );
171
+ },
172
+ };
@@ -0,0 +1,293 @@
1
+ /** Contact form types and render — split from contact-form-widget.tsx,
2
+ * which keeps the field definitions, so both stay under the 300-line cap. */
3
+
4
+ import { type FormEvent, useId, useState } from 'react';
5
+ import { parseList } from '../../utils/widgetUtils';
6
+ import { AccentText } from './_internal/accent-text';
7
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
8
+
9
+ export type ContactFormWidgetProps = {
10
+ variant: 'boxed' | 'underline';
11
+ /**
12
+ * Optional CSS color for the form panel. When set, the panel renders on that
13
+ * color with inverse (white) labels, underline fields and an outline pill
14
+ * submit; blank keeps the default grey panel.
15
+ */
16
+ surfaceColor: string;
17
+ title: string;
18
+ subtitle: string;
19
+ nameLabel: string;
20
+ namePlaceholder: string;
21
+ orgLabel: string;
22
+ orgPlaceholder: string;
23
+ enquiryLabel: string;
24
+ enquiryOptions: string;
25
+ emailLabel: string;
26
+ emailPlaceholder: string;
27
+ messageLabel: string;
28
+ messagePlaceholder: string;
29
+ termsLabel: string;
30
+ submitLabel: string;
31
+ successTitle: string;
32
+ successBody: string;
33
+ };
34
+
35
+ const HEADING_STYLE = { color: 'var(--pb-heading-color)' };
36
+ const BODY_STYLE = { color: 'var(--pb-body-color)' };
37
+ // Text on a colored (surfaceColor) panel — the shared inverse-on-color token.
38
+ const INVERSE_STYLE = { color: 'var(--pb-surface-contrast, #ffffff)' };
39
+ const INPUT_CLASS =
40
+ 'w-full border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500';
41
+ const INPUT_STYLE = {
42
+ borderColor: 'var(--border)',
43
+ color: 'var(--pb-body-color)',
44
+ };
45
+ // Boxed field, inverse (on a colored panel): translucent white fill + rule.
46
+ const INPUT_INVERSE_CLASS =
47
+ 'w-full rounded-md border border-white/60 bg-white/10 px-3 py-2 placeholder:text-white/70 focus:outline-none focus:ring-2 focus:ring-white/50';
48
+ // Underline variant (GCA): borderless field with only a bottom rule, on a soft
49
+ // grey panel, label-as-placeholder.
50
+ const UNDERLINE_INPUT_CLASS =
51
+ 'w-full bg-transparent border-0 border-b border-[#686873]/50 px-0 py-4 text-xl text-[var(--pb-heading-color)] placeholder:text-[#686873] focus:outline-none focus:border-[var(--pb-heading-color)]';
52
+ // Underline field, inverse (on a colored panel): white rule + placeholder.
53
+ const UNDERLINE_INPUT_INVERSE_CLASS =
54
+ 'w-full bg-transparent border-0 border-b border-white/60 px-0 py-4 text-xl placeholder:text-white/70 focus:outline-none focus:border-white';
55
+
56
+ export function ContactFormRender({
57
+ variant,
58
+ surfaceColor,
59
+ title,
60
+ subtitle,
61
+ nameLabel,
62
+ namePlaceholder,
63
+ orgLabel,
64
+ orgPlaceholder,
65
+ enquiryLabel,
66
+ enquiryOptions,
67
+ emailLabel,
68
+ emailPlaceholder,
69
+ messageLabel,
70
+ messagePlaceholder,
71
+ termsLabel,
72
+ submitLabel,
73
+ successTitle,
74
+ successBody,
75
+ }: ContactFormWidgetProps) {
76
+ const nameId = useId();
77
+ const orgId = useId();
78
+ const enquiryId = useId();
79
+ const emailId = useId();
80
+ const messageId = useId();
81
+ const termsId = useId();
82
+ const [submitted, setSubmitted] = useState(false);
83
+ const options = parseList(enquiryOptions);
84
+ const underline = variant === 'underline';
85
+ // A colored panel flips fields/labels to inverse (white) styling.
86
+ const inverse = Boolean(surfaceColor);
87
+ const inputClass = inverse
88
+ ? underline
89
+ ? UNDERLINE_INPUT_INVERSE_CLASS
90
+ : INPUT_INVERSE_CLASS
91
+ : underline
92
+ ? UNDERLINE_INPUT_CLASS
93
+ : INPUT_CLASS;
94
+ const inputStyle = inverse ? INVERSE_STYLE : underline ? undefined : INPUT_STYLE;
95
+ const labelClass = underline ? 'sr-only' : 'block text-sm font-medium mb-1';
96
+ const labelStyle = inverse ? INVERSE_STYLE : HEADING_STYLE;
97
+ const panelBodyStyle = inverse ? INVERSE_STYLE : BODY_STYLE;
98
+
99
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
100
+ event.preventDefault();
101
+ setSubmitted(true);
102
+ }
103
+
104
+ return (
105
+ <section
106
+ className={
107
+ underline
108
+ ? 'container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8'
109
+ : 'container mx-auto py-[var(--pb-section-py,3rem)] px-4 max-w-2xl'
110
+ }
111
+ >
112
+ {(title || subtitle) && (
113
+ <div className="text-center mb-8">
114
+ {/* AccentText, not bare renderRichText: `font-light` (300) here makes
115
+ a plain <strong>'s relative `bolder` compute to 400 — no visible
116
+ change. */}
117
+ {title && (
118
+ <h2 className="text-3xl sm:text-4xl lg:text-5xl font-light" style={HEADING_STYLE}>
119
+ <AccentText text={title} />
120
+ </h2>
121
+ )}
122
+ {subtitle && (
123
+ <RichTextBlock text={subtitle} className="mt-3 text-lg" style={BODY_STYLE} />
124
+ )}
125
+ </div>
126
+ )}
127
+ <div
128
+ className={
129
+ inverse
130
+ ? 'mx-auto max-w-3xl rounded-[12px] px-6 py-10 sm:px-12 sm:py-12'
131
+ : underline
132
+ ? 'mx-auto max-w-3xl rounded-[12px] bg-[var(--pb-surface-muted,#f3f3f4)] px-6 py-10 sm:px-12 sm:py-12'
133
+ : ''
134
+ }
135
+ style={inverse ? { backgroundColor: surfaceColor } : undefined}
136
+ >
137
+ {submitted ? (
138
+ <div
139
+ className="rounded-md border px-6 py-8 text-center"
140
+ style={inverse ? { borderColor: 'rgba(255,255,255,0.6)' } : INPUT_STYLE}
141
+ >
142
+ {successTitle && (
143
+ <h3 className="text-xl font-medium" style={labelStyle}>
144
+ {renderRichText(successTitle)}
145
+ </h3>
146
+ )}
147
+ {successBody && (
148
+ <RichTextBlock text={successBody} className="mt-2" style={panelBodyStyle} />
149
+ )}
150
+ </div>
151
+ ) : (
152
+ <form className="space-y-4" onSubmit={handleSubmit}>
153
+ <div>
154
+ <label htmlFor={nameId} className={labelClass} style={labelStyle}>
155
+ {renderRichText(nameLabel)}
156
+ </label>
157
+ <input
158
+ id={nameId}
159
+ type="text"
160
+ required
161
+ placeholder={namePlaceholder}
162
+ className={inputClass}
163
+ style={inputStyle}
164
+ />
165
+ </div>
166
+ <div>
167
+ <label htmlFor={emailId} className={labelClass} style={labelStyle}>
168
+ {renderRichText(emailLabel)}
169
+ </label>
170
+ <input
171
+ id={emailId}
172
+ type="email"
173
+ required
174
+ placeholder={emailPlaceholder}
175
+ className={inputClass}
176
+ style={inputStyle}
177
+ />
178
+ </div>
179
+ {orgLabel && (
180
+ <div>
181
+ <label htmlFor={orgId} className={labelClass} style={labelStyle}>
182
+ {renderRichText(orgLabel)}
183
+ </label>
184
+ <input
185
+ id={orgId}
186
+ type="text"
187
+ placeholder={orgPlaceholder}
188
+ className={inputClass}
189
+ style={inputStyle}
190
+ />
191
+ </div>
192
+ )}
193
+ <div>
194
+ <label htmlFor={enquiryId} className={labelClass} style={labelStyle}>
195
+ {renderRichText(enquiryLabel)}
196
+ </label>
197
+ <select
198
+ id={enquiryId}
199
+ required
200
+ className={inputClass}
201
+ style={inputStyle}
202
+ defaultValue=""
203
+ >
204
+ <option value="" disabled>
205
+ {underline ? enquiryLabel || 'Select…' : 'Select…'}
206
+ </option>
207
+ {options.map((option) => (
208
+ <option key={option} value={option}>
209
+ {option}
210
+ </option>
211
+ ))}
212
+ </select>
213
+ </div>
214
+ <div>
215
+ <label htmlFor={messageId} className={labelClass} style={labelStyle}>
216
+ {renderRichText(messageLabel)}
217
+ </label>
218
+ <textarea
219
+ id={messageId}
220
+ required
221
+ placeholder={messagePlaceholder}
222
+ rows={5}
223
+ className={inputClass}
224
+ style={inputStyle}
225
+ />
226
+ </div>
227
+ <div className="flex items-start gap-2">
228
+ <input
229
+ id={termsId}
230
+ type="checkbox"
231
+ required
232
+ className="mt-1"
233
+ style={{
234
+ accentColor: inverse ? 'var(--pb-surface-contrast, #ffffff)' : 'var(--primary)',
235
+ }}
236
+ />
237
+ <label htmlFor={termsId} className="text-sm" style={panelBodyStyle}>
238
+ {/* `*word*` segments render in the accent colour (e.g. the
239
+ orange "Terms of Use" link in the Figma); on a colored
240
+ panel they invert to the surface-contrast color, underlined.
241
+ The rest is ordinary inline markdown, so the terms can also
242
+ carry a real `[link](/page/terms)`. */}
243
+ {renderRichText(termsLabel, {
244
+ renderEm: (content, key) => (
245
+ <span
246
+ key={key}
247
+ className={inverse ? 'font-semibold underline' : 'font-semibold'}
248
+ style={{
249
+ color: inverse
250
+ ? 'var(--pb-surface-contrast, #ffffff)'
251
+ : 'var(--pb-accent, #fa6e25)',
252
+ }}
253
+ >
254
+ {content}
255
+ </span>
256
+ ),
257
+ })}
258
+ </label>
259
+ </div>
260
+ <button
261
+ type="submit"
262
+ className={
263
+ inverse
264
+ ? 'inline-flex items-center justify-center rounded-full border px-6 py-3 font-semibold transition-colors hover:bg-white/10'
265
+ : underline
266
+ ? 'inline-flex items-center justify-center rounded-full bg-[var(--primary,#1a353e)] px-6 py-3 font-semibold text-[var(--primary-foreground,#fff)] transition-opacity hover:opacity-90'
267
+ : 'inline-flex items-center justify-center px-5 py-2.5 rounded-md bg-primary-700 hover:bg-primary-800 text-white font-medium'
268
+ }
269
+ style={
270
+ inverse
271
+ ? {
272
+ borderColor: 'var(--pb-surface-contrast, #ffffff)',
273
+ color: 'var(--pb-surface-contrast, #ffffff)',
274
+ backgroundColor: 'transparent',
275
+ }
276
+ : undefined
277
+ }
278
+ >
279
+ {renderRichText(submitLabel, { allowLinks: false })}
280
+ {/* Trailing arrow, off by default (`--pb-button-arrow-display`). */}
281
+ <span
282
+ aria-hidden="true"
283
+ className="ml-2 [display:var(--pb-button-arrow-display,none)]"
284
+ >
285
+ →
286
+ </span>
287
+ </button>
288
+ </form>
289
+ )}
290
+ </div>
291
+ </section>
292
+ );
293
+ }
@@ -0,0 +1,69 @@
1
+ /** Contact form widget: field definitions and config assembly.
2
+ * Types and render live in contact-form-render.tsx. */
3
+
4
+ import type { ComponentConfig } from '@puckeditor/core';
5
+
6
+ import { ContactFormRender, type ContactFormWidgetProps } from './contact-form-render';
7
+
8
+ export type { ContactFormWidgetProps } from './contact-form-render';
9
+
10
+ export const ContactFormWidget: ComponentConfig<ContactFormWidgetProps> = {
11
+ label: 'Contact form',
12
+ fields: {
13
+ variant: {
14
+ type: 'select',
15
+ label: 'Style',
16
+ options: [
17
+ { label: 'Boxed inputs', value: 'boxed' },
18
+ { label: 'Underline inputs (panel)', value: 'underline' },
19
+ ],
20
+ },
21
+ surfaceColor: {
22
+ type: 'text',
23
+ label: 'Panel color (CSS, inverse text — optional)',
24
+ },
25
+ title: { type: 'text', label: 'Title' },
26
+ subtitle: { type: 'textarea', label: 'Subtitle' },
27
+ nameLabel: { type: 'text', label: 'Name field label' },
28
+ namePlaceholder: { type: 'text', label: 'Name placeholder' },
29
+ orgLabel: {
30
+ type: 'text',
31
+ label: 'Organisation field label (blank to hide)',
32
+ },
33
+ orgPlaceholder: { type: 'text', label: 'Organisation placeholder' },
34
+ enquiryLabel: { type: 'text', label: 'Enquiry type label' },
35
+ enquiryOptions: {
36
+ type: 'textarea',
37
+ label: 'Enquiry options (one per line)',
38
+ },
39
+ emailLabel: { type: 'text', label: 'Email field label' },
40
+ emailPlaceholder: { type: 'text', label: 'Email placeholder' },
41
+ messageLabel: { type: 'text', label: 'Message field label' },
42
+ messagePlaceholder: { type: 'text', label: 'Message placeholder' },
43
+ termsLabel: { type: 'text', label: 'Terms agreement label' },
44
+ submitLabel: { type: 'text', label: 'Submit button' },
45
+ successTitle: { type: 'text', label: 'Success title' },
46
+ successBody: { type: 'textarea', label: 'Success body' },
47
+ },
48
+ defaultProps: {
49
+ variant: 'boxed',
50
+ surfaceColor: '',
51
+ title: 'Contact us',
52
+ subtitle: "We'd love to hear from you.",
53
+ nameLabel: 'Name',
54
+ namePlaceholder: 'Your name',
55
+ orgLabel: 'Organisation name',
56
+ orgPlaceholder: 'Your organisation',
57
+ enquiryLabel: 'Enquiry type',
58
+ enquiryOptions: 'General\nCollaborations\nData contributions\nPress & media\nScience',
59
+ emailLabel: 'Email',
60
+ emailPlaceholder: 'you@example.com',
61
+ messageLabel: 'Message',
62
+ messagePlaceholder: 'How can we help?',
63
+ termsLabel: 'I have read and agree to the Terms and Conditions',
64
+ submitLabel: 'Send message',
65
+ successTitle: 'Your message has been sent',
66
+ successBody: 'Thank you for contacting us, a team member will be in touch with you shortly',
67
+ },
68
+ render: (props) => <ContactFormRender {...props} />,
69
+ };
@@ -0,0 +1,93 @@
1
+ import type { ComponentConfig, Slot } from '@puckeditor/core';
2
+ import { cn } from '../../utils/widgetUtils';
3
+
4
+ export type ContainerWidgetProps = {
5
+ backgroundColor: string;
6
+ padding: 'none' | 'sm' | 'md' | 'lg';
7
+ maxWidth: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
8
+ rounded: 'none' | 'md' | 'lg' | 'xl';
9
+ container: Slot;
10
+ };
11
+
12
+ const PADDING_CLASS: Record<ContainerWidgetProps['padding'], string> = {
13
+ none: 'p-0',
14
+ sm: 'p-4',
15
+ md: 'p-8',
16
+ lg: 'p-12',
17
+ };
18
+
19
+ const MAX_WIDTH_CLASS: Record<ContainerWidgetProps['maxWidth'], string> = {
20
+ sm: 'max-w-screen-sm',
21
+ md: 'max-w-screen-md',
22
+ lg: 'max-w-screen-lg',
23
+ xl: 'max-w-screen-xl',
24
+ '2xl': 'max-w-screen-2xl',
25
+ full: 'max-w-full',
26
+ };
27
+
28
+ const ROUNDED_CLASS: Record<ContainerWidgetProps['rounded'], string> = {
29
+ none: 'rounded-none',
30
+ md: 'rounded-md',
31
+ lg: 'rounded-lg',
32
+ xl: 'rounded-xl',
33
+ };
34
+
35
+ export const ContainerWidget: ComponentConfig<ContainerWidgetProps> = {
36
+ label: 'Container',
37
+ fields: {
38
+ backgroundColor: { type: 'text', label: 'Background color (CSS)' },
39
+ padding: {
40
+ type: 'select',
41
+ label: 'Padding',
42
+ options: [
43
+ { label: 'None', value: 'none' },
44
+ { label: 'Small', value: 'sm' },
45
+ { label: 'Medium', value: 'md' },
46
+ { label: 'Large', value: 'lg' },
47
+ ],
48
+ },
49
+ maxWidth: {
50
+ type: 'select',
51
+ label: 'Max width',
52
+ options: [
53
+ { label: 'Small', value: 'sm' },
54
+ { label: 'Medium', value: 'md' },
55
+ { label: 'Large', value: 'lg' },
56
+ { label: 'Extra Large', value: 'xl' },
57
+ { label: '2XL', value: '2xl' },
58
+ { label: 'Full', value: 'full' },
59
+ ],
60
+ },
61
+ rounded: {
62
+ type: 'select',
63
+ label: 'Rounded',
64
+ options: [
65
+ { label: 'None', value: 'none' },
66
+ { label: 'Medium', value: 'md' },
67
+ { label: 'Large', value: 'lg' },
68
+ { label: 'Extra Large', value: 'xl' },
69
+ ],
70
+ },
71
+ container: { type: 'slot' },
72
+ },
73
+ defaultProps: {
74
+ backgroundColor: '',
75
+ padding: 'md',
76
+ maxWidth: 'xl',
77
+ rounded: 'none',
78
+ container: [],
79
+ },
80
+ render: ({ backgroundColor, padding, maxWidth, rounded, container: Container }) => (
81
+ <div
82
+ className={cn(
83
+ 'mx-auto',
84
+ PADDING_CLASS[padding],
85
+ MAX_WIDTH_CLASS[maxWidth],
86
+ ROUNDED_CLASS[rounded],
87
+ )}
88
+ style={backgroundColor ? { backgroundColor } : undefined}
89
+ >
90
+ <Container />
91
+ </div>
92
+ ),
93
+ };