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,182 @@
1
+ /** Site header for the layout's header slot: utility bar + primary nav bar. */
2
+
3
+ import type { ComponentConfig } from '@puckeditor/core';
4
+ import { Menu, X } from 'lucide-react';
5
+ import { useId, useState } from 'react';
6
+
7
+ import { createCheckboxField, createImageField, mediaLibraryAdapter } from '../../fields';
8
+ import { SOCIAL_ICON_OPTIONS, SocialIcon, type SocialIconName } from './_shared';
9
+ import { DesktopNav, MobileNav, type SiteNavItem } from './site-header-nav';
10
+
11
+ export interface UtilityLink {
12
+ label: string;
13
+ href: string;
14
+ /** Empty renders the label as text; otherwise the icon replaces it. */
15
+ icon?: SocialIconName | '';
16
+ }
17
+
18
+ export interface SiteHeaderWidgetProps {
19
+ logoUrl: string;
20
+ logoAlt: string;
21
+ homeHref: string;
22
+ utilityLinks: UtilityLink[];
23
+ navItems: SiteNavItem[];
24
+ ctaLabel: string;
25
+ ctaHref: string;
26
+ sticky: boolean;
27
+ }
28
+
29
+ function UtilityBar({ links }: { links: UtilityLink[] }) {
30
+ if (links.length === 0) return null;
31
+ return (
32
+ <div className="bg-primary-800 text-white">
33
+ <div className="mx-auto flex h-10 max-w-[1440px] items-center justify-end gap-4 px-4 text-[13px] sm:gap-6 sm:px-6 lg:px-12">
34
+ {links.map((link, index) => (
35
+ <a
36
+ // biome-ignore lint/suspicious/noArrayIndexKey: author rows are not uniquely identified until filled in
37
+ key={index}
38
+ href={link.href || '#'}
39
+ aria-label={link.icon ? link.label : undefined}
40
+ className="opacity-90 transition-opacity hover:opacity-100"
41
+ >
42
+ {link.icon ? <SocialIcon name={link.icon} className="size-4" /> : link.label}
43
+ </a>
44
+ ))}
45
+ </div>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export function SiteHeaderRender({
51
+ logoUrl,
52
+ logoAlt,
53
+ homeHref,
54
+ utilityLinks,
55
+ navItems,
56
+ ctaLabel,
57
+ ctaHref,
58
+ sticky,
59
+ }: SiteHeaderWidgetProps) {
60
+ const [mobileOpen, setMobileOpen] = useState(false);
61
+ const mobileMenuId = useId();
62
+
63
+ return (
64
+ // The public page wraps this slot in its own <header>, which is exactly as
65
+ // tall as this block — so `position: sticky` here would have nothing to
66
+ // travel within. The attribute lets widgets-base.css make that *wrapper*
67
+ // sticky instead; see the `:has()` rule there.
68
+ <div data-pb-site-header={sticky ? 'sticky' : 'static'} className="gca-display">
69
+ <UtilityBar links={utilityLinks} />
70
+
71
+ <div className="border-b border-[color:var(--border,#e4e6e7)] bg-[var(--pb-surface,#fff)]">
72
+ <div className="mx-auto flex h-[72px] max-w-[1440px] items-center justify-between px-4 sm:px-6 lg:h-[112px] lg:px-12">
73
+ <a href={homeHref || '/'} aria-label={logoAlt || 'Home'}>
74
+ {logoUrl ? (
75
+ <img src={logoUrl} alt={logoAlt} className="h-10 w-auto lg:h-16" />
76
+ ) : (
77
+ <span className="text-sm font-bold uppercase leading-tight tracking-wide text-primary-800">
78
+ {logoAlt || 'Home'}
79
+ </span>
80
+ )}
81
+ </a>
82
+
83
+ <DesktopNav items={navItems} />
84
+
85
+ {ctaLabel && (
86
+ <a
87
+ href={ctaHref || '#'}
88
+ className="hidden rounded-[var(--pb-button-radius,9999px)] bg-primary-800 px-6 py-2.5 text-[17px] font-semibold text-white transition-colors hover:bg-primary-900 lg:inline-block"
89
+ >
90
+ {ctaLabel}
91
+ </a>
92
+ )}
93
+
94
+ <button
95
+ type="button"
96
+ onClick={() => setMobileOpen((open) => !open)}
97
+ aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
98
+ aria-expanded={mobileOpen}
99
+ aria-controls={mobileMenuId}
100
+ className="-mr-2 inline-flex size-10 items-center justify-center rounded-md text-primary-800 transition-colors hover:text-primary-900 lg:hidden"
101
+ >
102
+ {mobileOpen ? <X className="size-6" /> : <Menu className="size-6" />}
103
+ </button>
104
+ </div>
105
+ </div>
106
+
107
+ {mobileOpen && (
108
+ <MobileNav
109
+ id={mobileMenuId}
110
+ items={navItems}
111
+ ctaLabel={ctaLabel}
112
+ ctaHref={ctaHref}
113
+ onNavigate={() => setMobileOpen(false)}
114
+ />
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ export const SiteHeaderWidget: ComponentConfig<SiteHeaderWidgetProps> = {
121
+ label: 'Site header (logo + navigation)',
122
+ fields: {
123
+ logoUrl: createImageField(mediaLibraryAdapter, 'Logo'),
124
+ logoAlt: { type: 'text', label: 'Logo alt text' },
125
+ homeHref: { type: 'text', label: 'Logo link' },
126
+ utilityLinks: {
127
+ type: 'array',
128
+ label: 'Utility bar (top strip)',
129
+ arrayFields: {
130
+ label: { type: 'text', label: 'Label' },
131
+ href: { type: 'text', label: 'Link' },
132
+ icon: {
133
+ type: 'select',
134
+ label: 'Show as icon (optional)',
135
+ options: [{ label: 'Text label', value: '' }, ...SOCIAL_ICON_OPTIONS],
136
+ },
137
+ },
138
+ defaultItemProps: { label: 'Link', href: '#', icon: '' },
139
+ getItemSummary: (item) => item.label || 'Link',
140
+ max: 8,
141
+ },
142
+ navItems: {
143
+ type: 'array',
144
+ label: 'Navigation',
145
+ arrayFields: {
146
+ label: { type: 'text', label: 'Label' },
147
+ href: { type: 'text', label: 'Link (ignored when sub-links are set)' },
148
+ children: {
149
+ type: 'array',
150
+ label: 'Sub-links (makes this a dropdown)',
151
+ arrayFields: {
152
+ label: { type: 'text', label: 'Label' },
153
+ href: { type: 'text', label: 'Link' },
154
+ },
155
+ defaultItemProps: { label: 'Sub-link', href: '#' },
156
+ getItemSummary: (child) => child.label || 'Sub-link',
157
+ max: 10,
158
+ },
159
+ },
160
+ defaultItemProps: { label: 'Section', href: '#', children: [] },
161
+ getItemSummary: (item) => item.label || 'Section',
162
+ max: 10,
163
+ },
164
+ ctaLabel: { type: 'text', label: 'Call-to-action label' },
165
+ ctaHref: { type: 'text', label: 'Call-to-action link' },
166
+ sticky: createCheckboxField('Stick to the top of the page on scroll'),
167
+ },
168
+ defaultProps: {
169
+ logoUrl: '',
170
+ logoAlt: 'Home',
171
+ homeHref: '/',
172
+ utilityLinks: [],
173
+ navItems: [
174
+ { label: 'Home', href: '/', children: [] },
175
+ { label: 'Contact', href: '#', children: [] },
176
+ ],
177
+ ctaLabel: '',
178
+ ctaHref: '#',
179
+ sticky: true,
180
+ },
181
+ render: SiteHeaderRender,
182
+ };
@@ -0,0 +1,83 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { createImageField, mediaLibraryAdapter } from '../../fields';
3
+ import { renderRichText } from './_internal/rich-text';
4
+
5
+ export type SocialBannerLink = {
6
+ label: string;
7
+ href: string;
8
+ iconUrl: string;
9
+ };
10
+
11
+ export type SocialBannerWidgetProps = {
12
+ heading: string;
13
+ subheading: string;
14
+ links: SocialBannerLink[];
15
+ };
16
+
17
+ export const SocialBannerWidget: ComponentConfig<SocialBannerWidgetProps> = {
18
+ label: 'Social banner (follow links)',
19
+ fields: {
20
+ heading: { type: 'text', label: 'Heading' },
21
+ subheading: { type: 'text', label: 'Subheading' },
22
+ links: {
23
+ type: 'array',
24
+ label: 'Links',
25
+ arrayFields: {
26
+ label: { type: 'text', label: 'Label' },
27
+ href: { type: 'text', label: 'URL' },
28
+ iconUrl: createImageField(mediaLibraryAdapter, 'Icon URL'),
29
+ },
30
+ defaultItemProps: {
31
+ label: 'Twitter',
32
+ href: '#',
33
+ iconUrl: '',
34
+ },
35
+ min: 1,
36
+ max: 10,
37
+ },
38
+ },
39
+ defaultProps: {
40
+ heading: 'Follow us',
41
+ subheading: 'Stay up to date with the latest news.',
42
+ links: [
43
+ { label: 'Twitter', href: '#', iconUrl: '' },
44
+ { label: 'GitHub', href: '#', iconUrl: '' },
45
+ ],
46
+ },
47
+ render: ({ heading, subheading, links }) => (
48
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
49
+ <div className="rounded-2xl bg-[var(--secondary,#1a353e)] px-6 py-14 text-center text-white sm:py-16">
50
+ {heading && (
51
+ <h2
52
+ className="mx-auto max-w-3xl leading-tight"
53
+ style={{
54
+ fontFamily: 'var(--pb-display-font)',
55
+ fontWeight: 'var(--pb-display-weight)',
56
+ fontSize: 'var(--pb-heading-lg, clamp(1.875rem, 1.3rem + 2.5vw, 3rem))',
57
+ }}
58
+ >
59
+ {renderRichText(heading)}
60
+ </h2>
61
+ )}
62
+ {subheading && <p className="mt-3 text-lg text-white/80">{renderRichText(subheading)}</p>}
63
+ {links && links.length > 0 && (
64
+ <div className="mt-6 flex flex-wrap items-center justify-center gap-8">
65
+ {links.map((link, idx) => (
66
+ <a
67
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
68
+ key={idx}
69
+ href={link.href || '#'}
70
+ className="inline-flex items-center gap-2 text-xl font-semibold transition-opacity hover:opacity-80"
71
+ >
72
+ {link.iconUrl && (
73
+ <img src={link.iconUrl} alt="" loading="lazy" className="size-5 object-contain" />
74
+ )}
75
+ {renderRichText(link.label, { allowLinks: false })}
76
+ </a>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </div>
81
+ </section>
82
+ ),
83
+ };
@@ -0,0 +1,200 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import type { CSSProperties } from 'react';
3
+ import { createImageField, mediaLibraryAdapter } from '../../fields';
4
+ import { cn } from '../../utils/widgetUtils';
5
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
6
+
7
+ export type StatItem = {
8
+ value: string;
9
+ label: string;
10
+ iconUrl?: string;
11
+ };
12
+
13
+ export type StatsWidgetProps = {
14
+ title: string;
15
+ subtitle: string;
16
+ items: StatItem[];
17
+ surface: 'default' | 'lime' | 'cards';
18
+ };
19
+
20
+ // Display tokens for the title/numbers — weight/tracking/font come from the
21
+ // per-tenant page-builder theme (see styles.css `:root` for Recodo defaults and
22
+ // `.gca-root` for the GCA overrides). Color is overridden in the lime surface.
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
+ };
28
+
29
+ export const StatsWidget: ComponentConfig<StatsWidgetProps> = {
30
+ label: 'Statistics',
31
+ fields: {
32
+ title: { type: 'text', label: 'Title' },
33
+ subtitle: { type: 'textarea', label: 'Subtitle' },
34
+ items: {
35
+ type: 'array',
36
+ label: 'Statistics',
37
+ arrayFields: {
38
+ value: { type: 'text', label: 'Value' },
39
+ label: { type: 'text', label: 'Label' },
40
+ iconUrl: createImageField(mediaLibraryAdapter, 'Icon image (optional)'),
41
+ },
42
+ defaultItemProps: { value: '100+', label: 'Item' },
43
+ min: 1,
44
+ max: 12,
45
+ },
46
+ surface: {
47
+ type: 'select',
48
+ label: 'Surface',
49
+ options: [
50
+ { label: 'Default', value: 'default' },
51
+ { label: 'Lime', value: 'lime' },
52
+ { label: 'Cards', value: 'cards' },
53
+ ],
54
+ },
55
+ },
56
+ defaultProps: {
57
+ title: "We're trusted by thousands",
58
+ subtitle: 'Numbers that speak for themselves',
59
+ items: [
60
+ { value: '10K+', label: 'Users' },
61
+ { value: '99.9%', label: 'Uptime' },
62
+ { value: '24/7', label: 'Support' },
63
+ ],
64
+ surface: 'default',
65
+ },
66
+ render: (props) => <StatsView {...props} />,
67
+ };
68
+
69
+ // Presentational stats — shared by the static base widget (above) and the app's
70
+ // live, data-driven override, so both render identically.
71
+ export function StatsView({ title, subtitle, items, surface }: StatsWidgetProps) {
72
+ const count = items?.length ?? 0;
73
+
74
+ if (surface === 'cards') {
75
+ return (
76
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
77
+ {count > 0 && (
78
+ <dl className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
79
+ {items.map((stat, idx) => (
80
+ <div
81
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
82
+ key={idx}
83
+ className="relative flex min-h-[142px] flex-col justify-center rounded-[12px] border border-[var(--border,#dcdcdf)] bg-[var(--pb-surface,#ffffff)] px-6 py-5"
84
+ >
85
+ {stat.iconUrl && (
86
+ <span className="absolute right-6 top-6 flex size-14 items-center justify-center rounded-lg bg-[var(--secondary,#42547e)]">
87
+ <img src={stat.iconUrl} alt="" className="size-8 object-contain" />
88
+ </span>
89
+ )}
90
+ <dd
91
+ className="text-[2.75rem] sm:text-[3.5rem] leading-none font-semibold"
92
+ style={{
93
+ fontFamily: 'var(--pb-display-font)',
94
+ color: 'var(--pb-heading-color,#161728)',
95
+ }}
96
+ >
97
+ {renderRichText(stat.value)}
98
+ </dd>
99
+ <dt className="mt-3 text-base font-semibold text-[var(--pb-accent,#fa6e25)]">
100
+ {renderRichText(stat.label)}
101
+ </dt>
102
+ </div>
103
+ ))}
104
+ </dl>
105
+ )}
106
+ </section>
107
+ );
108
+ }
109
+
110
+ if (surface === 'lime') {
111
+ // GCA "Stats item-5 column": left-aligned values and labels on the lime
112
+ // band, one line per value, thin ink dividers (SNAG_007).
113
+ return (
114
+ <section className="container mx-auto py-[var(--pb-section-py,3rem)] px-4 sm:px-6 lg:px-8">
115
+ {count > 0 && (
116
+ <div className="rounded-xl bg-[#d8ec29] px-6 py-8 lg:px-8 lg:py-10">
117
+ <dl className="flex flex-col justify-center sm:flex-row sm:flex-wrap lg:flex-nowrap">
118
+ {items.map((stat, idx) => (
119
+ <div
120
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
121
+ key={idx}
122
+ className={cn(
123
+ 'flex flex-1 flex-col px-4 py-4 sm:px-6',
124
+ idx > 0 ? 'sm:border-l sm:border-[#1a353e]/20' : '',
125
+ )}
126
+ >
127
+ <dd
128
+ className="order-1 text-[1.875rem] leading-[1.2] text-[#1a353e] sm:text-[2.125rem] xl:whitespace-nowrap"
129
+ style={DISPLAY_STYLE}
130
+ >
131
+ {renderRichText(stat.value)}
132
+ </dd>
133
+ <dt className="order-2 mt-2 text-[18px] text-[#1a353e]">
134
+ {renderRichText(stat.label)}
135
+ </dt>
136
+ </div>
137
+ ))}
138
+ </dl>
139
+ </div>
140
+ )}
141
+ </section>
142
+ );
143
+ }
144
+
145
+ return (
146
+ <section className="container mx-auto py-[var(--pb-section-py,3rem)] px-4 sm:px-6 lg:px-8 font-semibold">
147
+ <div className="max-w-4xl mx-auto text-center">
148
+ {title && (
149
+ <h2
150
+ className="text-3xl sm:text-4xl"
151
+ style={{
152
+ ...DISPLAY_STYLE,
153
+ color: 'var(--pb-heading-color)',
154
+ }}
155
+ >
156
+ {renderRichText(title)}
157
+ </h2>
158
+ )}
159
+ {subtitle && <RichTextBlock text={subtitle} className="mt-3 text-xl sm:mt-4" />}
160
+ </div>
161
+ {count > 0 && (
162
+ <dl
163
+ className="mt-10 text-center max-w-3xl mx-auto grid gap-8 grid-cols-1 stats-grid"
164
+ style={
165
+ {
166
+ '--stats-cols': count,
167
+ } as React.CSSProperties
168
+ }
169
+ >
170
+ {items.map((stat, idx) => (
171
+ <div
172
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; card content repeats, so a content key collides
173
+ key={idx}
174
+ className="flex flex-col items-center"
175
+ >
176
+ {stat.iconUrl && (
177
+ <img src={stat.iconUrl} alt="" className="mx-auto mb-3 h-10 w-10 object-contain" />
178
+ )}
179
+ <dt
180
+ className="order-2 mt-2 text-lg leading-6 font-medium"
181
+ style={{ color: 'var(--pb-body-color)' }}
182
+ >
183
+ {renderRichText(stat.label)}
184
+ </dt>
185
+ <dd
186
+ className="order-1 text-5xl"
187
+ style={{
188
+ ...DISPLAY_STYLE,
189
+ color: 'var(--pb-heading-color)',
190
+ }}
191
+ >
192
+ {renderRichText(stat.value)}
193
+ </dd>
194
+ </div>
195
+ ))}
196
+ </dl>
197
+ )}
198
+ </section>
199
+ );
200
+ }
@@ -0,0 +1,211 @@
1
+ import type { ComponentConfig } from '@puckeditor/core';
2
+ import { cn } from '../../utils/widgetUtils';
3
+ import { AccentText } from './_internal/accent-text';
4
+ import { RichTextBlock, renderRichText } from './_internal/rich-text';
5
+ import { eyebrowTextStyle } from './_shared';
6
+
7
+ export type StepItem = {
8
+ title: string;
9
+ description: string;
10
+ links: { label: string; href: string }[];
11
+ showAppBadges: boolean;
12
+ };
13
+
14
+ export type StepsWidgetProps = {
15
+ eyebrow: string;
16
+ title: string;
17
+ intro: string;
18
+ surface: 'default' | 'muted';
19
+ googlePlaySrc: string;
20
+ appStoreSrc: string;
21
+ items: StepItem[];
22
+ };
23
+
24
+ function ArrowUpRight() {
25
+ return (
26
+ <svg
27
+ viewBox="0 0 24 24"
28
+ fill="none"
29
+ stroke="currentColor"
30
+ strokeWidth={2}
31
+ strokeLinecap="round"
32
+ strokeLinejoin="round"
33
+ className="size-6 shrink-0 text-[color:var(--pb-link-arrow-color,currentColor)]"
34
+ aria-hidden="true"
35
+ >
36
+ <title>Open</title>
37
+ <path d="M7 17 17 7M9 7h8v8" />
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ export const StepsWidget: ComponentConfig<StepsWidgetProps> = {
43
+ label: 'Steps (how to)',
44
+ fields: {
45
+ eyebrow: { type: 'text', label: 'Eyebrow' },
46
+ title: { type: 'text', label: 'Title' },
47
+ intro: { type: 'textarea', label: 'Intro text' },
48
+ surface: {
49
+ type: 'select',
50
+ label: 'Surface',
51
+ options: [
52
+ { label: 'Default', value: 'default' },
53
+ { label: 'Muted', value: 'muted' },
54
+ ],
55
+ },
56
+ googlePlaySrc: { type: 'text', label: 'Google Play badge image' },
57
+ appStoreSrc: { type: 'text', label: 'App Store badge image' },
58
+ items: {
59
+ type: 'array',
60
+ label: 'Steps',
61
+ arrayFields: {
62
+ title: { type: 'text', label: 'Step title' },
63
+ description: { type: 'textarea', label: 'Description' },
64
+ links: {
65
+ type: 'array',
66
+ label: 'Links',
67
+ arrayFields: {
68
+ label: { type: 'text', label: 'Label' },
69
+ href: { type: 'text', label: 'Link' },
70
+ },
71
+ defaultItemProps: { label: 'Link', href: '#' },
72
+ },
73
+ showAppBadges: {
74
+ type: 'radio',
75
+ label: 'Show app store badges',
76
+ options: [
77
+ { label: 'Yes', value: true },
78
+ { label: 'No', value: false },
79
+ ],
80
+ },
81
+ },
82
+ defaultItemProps: {
83
+ title: 'Step',
84
+ description: '',
85
+ links: [],
86
+ showAppBadges: false,
87
+ },
88
+ min: 1,
89
+ max: 12,
90
+ },
91
+ },
92
+ defaultProps: {
93
+ eyebrow: '',
94
+ title: 'How to Contribute Data',
95
+ intro: '',
96
+ surface: 'muted',
97
+ googlePlaySrc: '',
98
+ appStoreSrc: '',
99
+ items: [
100
+ {
101
+ title: '1. Download Geo-Quest',
102
+ description: 'Get the app from the Google Play or Apple App Store.',
103
+ links: [],
104
+ showAppBadges: true,
105
+ },
106
+ ],
107
+ },
108
+ // Muted surface renders as a rounded card spanning the container (Mowing
109
+ // Figma "How to Contribute Data": 80px vertical padding, eyebrow inset 48px).
110
+ // Column geometry is tenant-tunable via `--pb-split-cols`/`--pb-split-gap`
111
+ // (Mowing aligns the content to the 4th page-grid column); the 1fr/2fr
112
+ // fallback keeps tenants without the tokens unchanged.
113
+ render: ({ eyebrow, title, intro, surface, googlePlaySrc, appStoreSrc, items }) => (
114
+ <section className="container mx-auto px-4 py-[var(--pb-section-py,3rem)] sm:px-6 lg:px-8">
115
+ <div
116
+ className={cn(
117
+ 'grid gap-y-6 md:grid-cols-[var(--pb-split-cols,1fr_2fr)] md:gap-x-[var(--pb-split-gap,2rem)]',
118
+ surface === 'muted' &&
119
+ 'rounded-[12px] bg-[var(--pb-surface-muted,#f3f3f4)] px-8 py-12 lg:px-0 lg:py-20',
120
+ )}
121
+ >
122
+ {eyebrow && (
123
+ <p
124
+ className={cn(surface === 'muted' && 'lg:pl-12')}
125
+ style={{
126
+ // Eyebrow size/weight/case/tracking are theme-driven; the
127
+ // fallbacks keep today's look for tenants that don't set them.
128
+ ...eyebrowTextStyle('1.125rem', '400'),
129
+ color: 'var(--pb-heading-color,#161728)',
130
+ }}
131
+ >
132
+ {renderRichText(eyebrow)}
133
+ </p>
134
+ )}
135
+ <div className={cn('max-w-2xl', !eyebrow && 'md:col-span-2')}>
136
+ {title && (
137
+ <h2
138
+ className="leading-tight"
139
+ style={{
140
+ color: 'var(--pb-heading-color)',
141
+ fontWeight: 'var(--pb-display-weight)',
142
+ fontFamily: 'var(--pb-display-font)',
143
+ fontSize: 'var(--pb-heading-md, clamp(1.875rem, 1.3rem + 1.4vw, 2.5rem))',
144
+ }}
145
+ >
146
+ {renderRichText(title)}
147
+ </h2>
148
+ )}
149
+ {intro && (
150
+ <RichTextBlock
151
+ text={intro}
152
+ className="mt-5 text-lg leading-[var(--pb-body-leading,1.625)] text-[var(--pb-body-color,#686873)]"
153
+ />
154
+ )}
155
+ <div className="mt-8 flex flex-col gap-10">
156
+ {items.map((step, idx) => (
157
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
158
+ <div key={idx}>
159
+ <h3 className="text-xl font-semibold" style={{ color: 'var(--pb-heading-color)' }}>
160
+ {renderRichText(step.title)}
161
+ </h3>
162
+ {step.description && (
163
+ // Inline AccentText, not RichTextBlock: step copy uses the
164
+ // tenant accent treatment for `*word*` (Mowing's Figma), and
165
+ // `whitespace-pre-line` keeps single newlines. Bullet lists
166
+ // are deliberately not supported here.
167
+ <p className="mt-2 whitespace-pre-line text-lg leading-[var(--pb-body-leading,1.625)] text-[var(--pb-body-color,#686873)]">
168
+ <AccentText text={step.description} />
169
+ </p>
170
+ )}
171
+ {step.showAppBadges && (googlePlaySrc || appStoreSrc) && (
172
+ <div className="mt-5 flex flex-wrap gap-4">
173
+ {googlePlaySrc && (
174
+ <img
175
+ src={googlePlaySrc}
176
+ alt="Get it on Google Play"
177
+ className="h-16 w-auto"
178
+ />
179
+ )}
180
+ {appStoreSrc && (
181
+ <img
182
+ src={appStoreSrc}
183
+ alt="Download on the App Store"
184
+ className="h-16 w-auto"
185
+ />
186
+ )}
187
+ </div>
188
+ )}
189
+ {step.links?.length > 0 && (
190
+ <div className="mt-5">
191
+ {step.links.map((l, i) => (
192
+ <a
193
+ // biome-ignore lint/suspicious/noArrayIndexKey: author-ordered array with no stable id; content repeats, so a content key collides
194
+ key={i}
195
+ href={l.href}
196
+ className="flex items-center justify-between gap-4 border-b border-[var(--border,#dcdcdf)] py-4 text-lg font-medium text-[color:var(--pb-heading-color,#161728)] transition-colors hover:text-[color:var(--pb-link-hover-color,var(--pb-heading-color,#161728))]"
197
+ >
198
+ {renderRichText(l.label, { allowLinks: false })}
199
+ <ArrowUpRight />
200
+ </a>
201
+ ))}
202
+ </div>
203
+ )}
204
+ </div>
205
+ ))}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </section>
210
+ ),
211
+ };