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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { type CSSProperties, Fragment, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
type MarkdownBlock,
|
|
4
|
+
parseInlineTokens,
|
|
5
|
+
parseMarkdownBlocks,
|
|
6
|
+
} from '../../../utils/markdown';
|
|
7
|
+
import { cn } from '../../../utils/widgetUtils';
|
|
8
|
+
|
|
9
|
+
// `[label](href)`. Split first, so a link's label can still carry the emphasis
|
|
10
|
+
// markers the inline tokenizer understands ("[**Datenschutz**](/x)").
|
|
11
|
+
//
|
|
12
|
+
// The href allows ONE level of balanced parentheses, because a naive `[^)]+`
|
|
13
|
+
// stops at the first `)` and silently truncates URLs that legitimately contain
|
|
14
|
+
// them (Wikipedia's `..._(disambiguation)`), producing a broken href AND
|
|
15
|
+
// leaking the remainder onto the page as stray literal text. Anything the
|
|
16
|
+
// pattern can't parse falls through unchanged and renders as literal text, so
|
|
17
|
+
// malformed input is always visible rather than dropped.
|
|
18
|
+
const LINK_PATTERN = /(\[[^\]]+\]\((?:[^\s()]|\([^\s()]*\))+\))/g;
|
|
19
|
+
|
|
20
|
+
// `<strong>`'s UA default `font-weight: bolder` is RELATIVE — inside
|
|
21
|
+
// `font-light` (300) copy it computes to 400 and the bold is invisible. An
|
|
22
|
+
// explicit weight keeps `**bold**` legible everywhere; `bold` (700) matches
|
|
23
|
+
// what normal-weight body copy already computed, and tenants can tune it.
|
|
24
|
+
const DEFAULT_STRONG_STYLE: CSSProperties = {
|
|
25
|
+
fontWeight: 'var(--pb-strong-weight, bold)' as CSSProperties['fontWeight'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type RichTextOptions = {
|
|
29
|
+
/**
|
|
30
|
+
* Render `[label](href)` as real anchors (default). Pass `false` where the
|
|
31
|
+
* output already lives inside an interactive element (`<a>`, `<button>`,
|
|
32
|
+
* `<summary>`) — nested anchors are invalid HTML and browsers split them.
|
|
33
|
+
*
|
|
34
|
+
* With `false` a link renders as its LABEL TEXT and the href is DROPPED
|
|
35
|
+
* silently. That is the only sane rendering inside an anchor, but it means
|
|
36
|
+
* body-copy call sites (card bodies, action-list descriptions, FAQ answers
|
|
37
|
+
* that live inside a link) cannot carry their own links — prefer moving
|
|
38
|
+
* such copy outside the interactive element if the destination matters.
|
|
39
|
+
* Those fields say "links not supported here" in their Puck label.
|
|
40
|
+
*
|
|
41
|
+
* Either way links are split out before emphasis is tokenized, so emphasis
|
|
42
|
+
* that STRADDLES a link (`**bold [x](/y) more**`) can't pair up and its
|
|
43
|
+
* markers stay literal. Wrap the emphasis inside the label instead.
|
|
44
|
+
*/
|
|
45
|
+
allowLinks?: boolean;
|
|
46
|
+
/** Override the `<em>` produced by `*x*` (AccentText's tenant accent). */
|
|
47
|
+
renderEm?: (content: string, key: string) => ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Override the `<strong>` produced by `**x**`. Display headings need this:
|
|
50
|
+
* a bare `<strong>`'s `font-weight: bolder` is RELATIVE, so inside a
|
|
51
|
+
* `font-light` (300) heading it computes to 400 and looks unchanged.
|
|
52
|
+
*/
|
|
53
|
+
renderStrong?: (content: ReactNode, key: string) => ReactNode;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function renderEmphasis(text: string, keyPrefix: string, opts?: RichTextOptions): ReactNode[] {
|
|
57
|
+
const em = opts?.renderEm ?? ((content: string, key: string) => <em key={key}>{content}</em>);
|
|
58
|
+
const strong =
|
|
59
|
+
opts?.renderStrong ??
|
|
60
|
+
((content: ReactNode, key: string) => (
|
|
61
|
+
<strong key={key} style={DEFAULT_STRONG_STYLE}>
|
|
62
|
+
{content}
|
|
63
|
+
</strong>
|
|
64
|
+
));
|
|
65
|
+
return parseInlineTokens(text).map((token, i) => {
|
|
66
|
+
const key = `${keyPrefix}-${i}`;
|
|
67
|
+
if (token.type === 'strongem') return strong(em(token.content, `${key}-em`), key);
|
|
68
|
+
if (token.type === 'strong') return strong(token.content, key);
|
|
69
|
+
if (token.type === 'em') return em(token.content, key);
|
|
70
|
+
if (token.type === 'code')
|
|
71
|
+
return (
|
|
72
|
+
// The text colour is explicit, never inherited: on an inverted
|
|
73
|
+
// surface (hero, colored CTA band) the inherited colour is white,
|
|
74
|
+
// which on this light chip is invisible (1.1:1 contrast). Kept as
|
|
75
|
+
// a light/dark class pair rather than a `--pb-*` token because one
|
|
76
|
+
// token cannot carry both themes' values.
|
|
77
|
+
<code
|
|
78
|
+
key={key}
|
|
79
|
+
className="rounded bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100 px-1.5 py-0.5 text-sm font-mono"
|
|
80
|
+
>
|
|
81
|
+
{token.content}
|
|
82
|
+
</code>
|
|
83
|
+
);
|
|
84
|
+
return token.content;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Editor copy is not fully trusted (ContentEditors can write these fields),
|
|
90
|
+
* so only well-known schemes and scheme-less (relative / anchor) hrefs become
|
|
91
|
+
* anchors — `javascript:` & co. fall through and render as literal text,
|
|
92
|
+
* visible rather than executable.
|
|
93
|
+
*/
|
|
94
|
+
function isSafeHref(href: string): boolean {
|
|
95
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(href);
|
|
96
|
+
if (!scheme) return true;
|
|
97
|
+
return ['http', 'https', 'mailto', 'tel'].includes(scheme[1].toLowerCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Inline markdown → ReactNodes, with links.
|
|
102
|
+
*
|
|
103
|
+
* The shared `parseInlineTokens` covers emphasis/code only, so `[x](y)` used to
|
|
104
|
+
* render as literal text everywhere it was accepted. Anchors carry
|
|
105
|
+
* `--pb-prose-link-color` so a tenant can colour body links (BioGarden's Figma
|
|
106
|
+
* uses the brand purple) without restyling every link on the page.
|
|
107
|
+
*/
|
|
108
|
+
export function renderRichText(
|
|
109
|
+
text: string | undefined | null,
|
|
110
|
+
opts?: RichTextOptions,
|
|
111
|
+
): ReactNode[] {
|
|
112
|
+
// Legacy Puck JSON saved before a field existed passes undefined where the
|
|
113
|
+
// prop type says string; `{prop}` rendered nothing, so render nothing —
|
|
114
|
+
// don't let one missing field throw and blank the whole page.
|
|
115
|
+
if (!text) return [];
|
|
116
|
+
return collapse(renderRichTextNodes(text, opts));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Multi-node output collapses into ONE `<span>`: many render sites are flex
|
|
121
|
+
* containers (`inline-flex gap-2` labels, `justify-between` summaries), where
|
|
122
|
+
* each node would otherwise become its own flex item and `gap` would scatter
|
|
123
|
+
* the fragments. Plain text stays a bare string — single-node output is
|
|
124
|
+
* returned as-is, so unmarked copy renders byte-identically to before.
|
|
125
|
+
*/
|
|
126
|
+
function collapse(nodes: ReactNode[]): ReactNode[] {
|
|
127
|
+
if (nodes.length <= 1) return nodes;
|
|
128
|
+
return [<span key="rt">{nodes}</span>];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderRichTextNodes(text: string, opts?: RichTextOptions): ReactNode[] {
|
|
132
|
+
const linksAllowed = opts?.allowLinks !== false;
|
|
133
|
+
const out: ReactNode[] = [];
|
|
134
|
+
const parts = text.split(LINK_PATTERN);
|
|
135
|
+
for (let i = 0; i < parts.length; i++) {
|
|
136
|
+
const part = parts[i];
|
|
137
|
+
if (!part) continue;
|
|
138
|
+
const match = /^\[([^\]]+)\]\(((?:[^\s()]|\([^\s()]*\))+)\)$/.exec(part);
|
|
139
|
+
if (match && isSafeHref(match[2])) {
|
|
140
|
+
const [, label, href] = match;
|
|
141
|
+
// Inside an `<a>`/`<button>`/`<summary>` an anchor can't be nested, so
|
|
142
|
+
// keep the label (emphasis and all) and drop the href — showing the
|
|
143
|
+
// raw `[label](href)` markup reads as broken copy to editors.
|
|
144
|
+
if (!linksAllowed) {
|
|
145
|
+
out.push(...renderEmphasis(label, `u-${i}`, opts));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Scheme-relative `//host/...` is off-site too — it must get the
|
|
149
|
+
// same new-tab + noopener treatment as absolute http(s) links.
|
|
150
|
+
const external = /^(?:https?:)?\/\//.test(href);
|
|
151
|
+
out.push(
|
|
152
|
+
<a
|
|
153
|
+
key={`l-${i}`}
|
|
154
|
+
href={href}
|
|
155
|
+
className="underline underline-offset-2 text-[color:var(--pb-prose-link-color,inherit)]"
|
|
156
|
+
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
157
|
+
>
|
|
158
|
+
{renderEmphasis(label, `l-${i}`, opts)}
|
|
159
|
+
</a>,
|
|
160
|
+
);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
out.push(...renderEmphasis(part, `t-${i}`, opts));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type RichTextBlockProps = {
|
|
169
|
+
text: string | undefined;
|
|
170
|
+
className?: string;
|
|
171
|
+
style?: CSSProperties;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Block-level light markdown for `textarea` widget fields: blank-line
|
|
176
|
+
* paragraphs, `- ` bullet lists, and everything `renderRichText` does inline.
|
|
177
|
+
*
|
|
178
|
+
* Single-paragraph content (the common case) renders the same `<p>` the
|
|
179
|
+
* widget rendered before — zero DOM/visual change for plain text. Multi-block
|
|
180
|
+
* content wraps in a `<div>` carrying the caller's className/style so the
|
|
181
|
+
* widget's spacing and typography still apply.
|
|
182
|
+
*
|
|
183
|
+
* Heading markers (`#`) are demoted to bold paragraphs on purpose: widget
|
|
184
|
+
* copy must not inject `<h1>`–`<h3>` and break the page's real heading
|
|
185
|
+
* hierarchy — full headings stay the job of the Markdown widget and Heading
|
|
186
|
+
* fields.
|
|
187
|
+
*/
|
|
188
|
+
export function RichTextBlock({ text, className, style }: RichTextBlockProps): ReactNode {
|
|
189
|
+
// softBreaks: several widget textareas rendered with `whitespace-pre-line`
|
|
190
|
+
// before markdown support, so a bare newline has always been a visible
|
|
191
|
+
// line break there — keep that meaning instead of markdown's space-join.
|
|
192
|
+
const blocks = parseMarkdownBlocks(text ?? '', { softBreaks: true });
|
|
193
|
+
if (blocks.length === 0) return null;
|
|
194
|
+
const only = blocks.length === 1 ? blocks[0] : undefined;
|
|
195
|
+
if (only && only.type === 'p') {
|
|
196
|
+
return (
|
|
197
|
+
<p className={className} style={style}>
|
|
198
|
+
{renderWithSoftBreaks(only.text)}
|
|
199
|
+
</p>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return (
|
|
203
|
+
<div className={className} style={style}>
|
|
204
|
+
{blocks.map((block, i) => renderBlock(block, i))}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Inline rich text where remaining `\n`s (softBreaks parsing) become `<br/>`. */
|
|
210
|
+
function renderWithSoftBreaks(text: string): ReactNode[] {
|
|
211
|
+
return text.split('\n').flatMap((line, i) => {
|
|
212
|
+
// Each line's nodes go in a keyed fragment so renderRichText's
|
|
213
|
+
// per-line keys ("t-0", ...) can't collide across lines.
|
|
214
|
+
//
|
|
215
|
+
// Index is the only identity these have: they're positional slices of one
|
|
216
|
+
// string, re-derived on every render, with nothing stable to key on.
|
|
217
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: positional slices of a string have no stable id
|
|
218
|
+
const nodes = <Fragment key={`ln-${i}`}>{renderRichText(line)}</Fragment>;
|
|
219
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: same — the <br> is positional too
|
|
220
|
+
return i === 0 ? [nodes] : [<br key={`br-${i}`} />, nodes];
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderBlock(block: MarkdownBlock, key: number): ReactNode {
|
|
225
|
+
if (block.type === 'ul') {
|
|
226
|
+
return (
|
|
227
|
+
<ul key={key} className="list-disc pl-5 [&:not(:first-child)]:mt-3">
|
|
228
|
+
{block.items.map((li, j) => (
|
|
229
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list items are positional slices of the source text
|
|
230
|
+
<li key={j}>{renderRichText(li)}</li>
|
|
231
|
+
))}
|
|
232
|
+
</ul>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return (
|
|
236
|
+
<p
|
|
237
|
+
key={key}
|
|
238
|
+
className={cn('[&:not(:first-child)]:mt-3', block.type !== 'p' && 'font-semibold')}
|
|
239
|
+
>
|
|
240
|
+
{renderWithSoftBreaks(block.text)}
|
|
241
|
+
</p>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cn } from '../../../utils/widgetUtils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Solid (filled) icon set matching the GCA Design System "Icons" Figma page.
|
|
5
|
+
* Glyphs are Font Awesome Free 6.7.2 solid icons
|
|
6
|
+
* (https://fontawesome.com — License: CC BY 4.0,
|
|
7
|
+
* https://creativecommons.org/licenses/by/4.0/); path data unmodified.
|
|
8
|
+
* Rendered with `fill: currentColor` so the consumer controls the tint the
|
|
9
|
+
* same way it does for the lucide stroke icons these replace. The svgs are
|
|
10
|
+
* decorative (aria-hidden, no <title> — a title pops a native tooltip).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type SolidIconProps = { className?: string };
|
|
14
|
+
|
|
15
|
+
function makeIcon(title: string, viewBox: string, d: string) {
|
|
16
|
+
function Icon({ className }: SolidIconProps) {
|
|
17
|
+
return (
|
|
18
|
+
<svg
|
|
19
|
+
viewBox={viewBox}
|
|
20
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
21
|
+
fill="currentColor"
|
|
22
|
+
className={cn('size-6 shrink-0', className)}
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
>
|
|
25
|
+
<path d={d} />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
Icon.displayName = `SolidIcon(${title})`;
|
|
30
|
+
return Icon;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** fa-solid fa-chart-area — carbon mapping / area statistics. */
|
|
34
|
+
export const ChartAreaSolid = makeIcon(
|
|
35
|
+
'Area chart',
|
|
36
|
+
'0 0 512 512',
|
|
37
|
+
'M64 64c0-17.7-14.3-32-32-32S0 46.3 0 64L0 400c0 44.2 35.8 80 80 80l400 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 416c-8.8 0-16-7.2-16-16L64 64zm96 288l288 0c17.7 0 32-14.3 32-32l0-68.2c0-7.6-2.7-15-7.7-20.8l-65.8-76.8c-12.1-14.2-33.7-15-46.9-1.8l-21 21c-10 10-26.4 9.2-35.4-1.6l-39.2-47c-12.6-15.1-35.7-15.4-48.7-.6L135.9 215c-5.1 5.8-7.9 13.3-7.9 21.1l0 84c0 17.7 14.3 32 32 32z',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** fa-solid fa-magnifying-glass-chart — tracking disturbance. */
|
|
41
|
+
export const MagnifyingGlassChartSolid = makeIcon(
|
|
42
|
+
'Magnifying glass with chart',
|
|
43
|
+
'0 0 512 512',
|
|
44
|
+
'M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zm-312 8l0 64c0 13.3 10.7 24 24 24s24-10.7 24-24l0-64c0-13.3-10.7-24-24-24s-24 10.7-24 24zm80-96l0 160c0 13.3 10.7 24 24 24s24-10.7 24-24l0-160c0-13.3-10.7-24-24-24s-24 10.7-24 24zm80 64l0 96c0 13.3 10.7 24 24 24s24-10.7 24-24l0-96c0-13.3-10.7-24-24-24s-24 10.7-24 24z',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** fa-solid fa-temperature-three-quarters — microclimate. */
|
|
48
|
+
export const TemperatureSolid = makeIcon(
|
|
49
|
+
'Thermometer',
|
|
50
|
+
'0 0 320 512',
|
|
51
|
+
'M160 64c-26.5 0-48 21.5-48 48l0 164.5c0 17.3-7.1 31.9-15.3 42.5C86.2 332.6 80 349.5 80 368c0 44.2 35.8 80 80 80s80-35.8 80-80c0-18.5-6.2-35.4-16.7-48.9c-8.2-10.6-15.3-25.2-15.3-42.5L208 112c0-26.5-21.5-48-48-48zM48 112C48 50.2 98.1 0 160 0s112 50.1 112 112l0 164.4c0 .1 .1 .3 .2 .6c.2 .6 .8 1.6 1.7 2.8c18.9 24.4 30.1 55 30.1 88.1c0 79.5-64.5 144-144 144S16 447.5 16 368c0-33.2 11.2-63.8 30.1-88.1c.9-1.2 1.5-2.2 1.7-2.8c.1-.3 .2-.5 .2-.6L48 112zM208 368c0 26.5-21.5 48-48 48s-48-21.5-48-48c0-20.9 13.4-38.7 32-45.3L144 144c0-8.8 7.2-16 16-16s16 7.2 16 16l0 178.7c18.6 6.6 32 24.4 32 45.3z',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** fa-solid fa-tree — forestry restoration. */
|
|
55
|
+
export const TreeSolid = makeIcon(
|
|
56
|
+
'Tree',
|
|
57
|
+
'0 0 448 512',
|
|
58
|
+
'M210.6 5.9L62 169.4c-3.9 4.2-6 9.8-6 15.5C56 197.7 66.3 208 79.1 208l24.9 0L30.6 281.4c-4.2 4.2-6.6 10-6.6 16C24 309.9 34.1 320 46.6 320L80 320 5.4 409.5C1.9 413.7 0 419 0 424.5c0 13 10.5 23.5 23.5 23.5L192 448l0 32c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32 168.5 0c13 0 23.5-10.5 23.5-23.5c0-5.5-1.9-10.8-5.4-15L368 320l33.4 0c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16L344 208l24.9 0c12.7 0 23.1-10.3 23.1-23.1c0-5.7-2.1-11.3-6-15.5L237.4 5.9C234 2.1 229.1 0 224 0s-10 2.1-13.4 5.9z',
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/** fa-solid fa-paw — habitat mapping. */
|
|
62
|
+
export const PawSolid = makeIcon(
|
|
63
|
+
'Paw print',
|
|
64
|
+
'0 0 512 512',
|
|
65
|
+
'M226.5 92.9c14.3 42.9-.3 86.2-32.6 96.8s-70.1-15.6-84.4-58.5s.3-86.2 32.6-96.8s70.1 15.6 84.4 58.5zM100.4 198.6c18.9 32.4 14.3 70.1-10.2 84.1s-59.7-.9-78.5-33.3S-2.7 179.3 21.8 165.3s59.7 .9 78.5 33.3zM69.2 401.2C121.6 259.9 214.7 224 256 224s134.4 35.9 186.8 177.2c3.6 9.7 5.2 20.1 5.2 30.5l0 1.6c0 25.8-20.9 46.7-46.7 46.7c-11.5 0-22.9-1.4-34-4.2l-88-22c-15.3-3.8-31.3-3.8-46.6 0l-88 22c-11.1 2.8-22.5 4.2-34 4.2C84.9 480 64 459.1 64 433.3l0-1.6c0-10.4 1.6-20.8 5.2-30.5zM421.8 282.7c-24.5-14-29.1-51.7-10.2-84.1s54-47.3 78.5-33.3s29.1 51.7 10.2 84.1s-54 47.3-78.5 33.3zM310.1 189.7c-32.3-10.6-46.9-53.9-32.6-96.8s52.1-69.1 84.4-58.5s46.9 53.9 32.6 96.8s-52.1 69.1-84.4 58.5z',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** fa-solid fa-water — flooding and erosion. */
|
|
69
|
+
export const WaterSolid = makeIcon(
|
|
70
|
+
'Water waves',
|
|
71
|
+
'0 0 576 512',
|
|
72
|
+
'M269.5 69.9c11.1-7.9 25.9-7.9 37 0C329 85.4 356.5 96 384 96c26.9 0 55.4-10.8 77.4-26.1c0 0 0 0 0 0c11.9-8.5 28.1-7.8 39.2 1.7c14.4 11.9 32.5 21 50.6 25.2c17.2 4 27.9 21.2 23.9 38.4s-21.2 27.9-38.4 23.9c-24.5-5.7-44.9-16.5-58.2-25C449.5 149.7 417 160 384 160c-31.9 0-60.6-9.9-80.4-18.9c-5.8-2.7-11.1-5.3-15.6-7.7c-4.5 2.4-9.7 5.1-15.6 7.7c-19.8 9-48.5 18.9-80.4 18.9c-33 0-65.5-10.3-94.5-25.8c-13.4 8.4-33.7 19.3-58.2 25c-17.2 4-34.4-6.7-38.4-23.9s6.7-34.4 23.9-38.4C42.8 92.6 61 83.5 75.3 71.6c11.1-9.5 27.3-10.1 39.2-1.7c0 0 0 0 0 0C136.7 85.2 165.1 96 192 96c27.5 0 55-10.6 77.5-26.1zm37 288C329 373.4 356.5 384 384 384c26.9 0 55.4-10.8 77.4-26.1c0 0 0 0 0 0c11.9-8.5 28.1-7.8 39.2 1.7c14.4 11.9 32.5 21 50.6 25.2c17.2 4 27.9 21.2 23.9 38.4s-21.2 27.9-38.4 23.9c-24.5-5.7-44.9-16.5-58.2-25C449.5 437.7 417 448 384 448c-31.9 0-60.6-9.9-80.4-18.9c-5.8-2.7-11.1-5.3-15.6-7.7c-4.5 2.4-9.7 5.1-15.6 7.7c-19.8 9-48.5 18.9-80.4 18.9c-33 0-65.5-10.3-94.5-25.8c-13.4 8.4-33.7 19.3-58.2 25c-17.2 4-34.4-6.7-38.4-23.9s6.7-34.4 23.9-38.4c18.1-4.2 36.2-13.3 50.6-25.2c11.1-9.4 27.3-10.1 39.2-1.7c0 0 0 0 0 0C136.7 373.2 165.1 384 192 384c27.5 0 55-10.6 77.5-26.1c11.1-7.9 25.9-7.9 37 0zm0-144C329 229.4 356.5 240 384 240c26.9 0 55.4-10.8 77.4-26.1c0 0 0 0 0 0c11.9-8.5 28.1-7.8 39.2 1.7c14.4 11.9 32.5 21 50.6 25.2c17.2 4 27.9 21.2 23.9 38.4s-21.2 27.9-38.4 23.9c-24.5-5.7-44.9-16.5-58.2-25C449.5 293.7 417 304 384 304c-31.9 0-60.6-9.9-80.4-18.9c-5.8-2.7-11.1-5.3-15.6-7.7c-4.5 2.4-9.7 5.1-15.6 7.7c-19.8 9-48.5 18.9-80.4 18.9c-33 0-65.5-10.3-94.5-25.8c-13.4 8.4-33.7 19.3-58.2 25c-17.2 4-34.4-6.7-38.4-23.9s6.7-34.4 23.9-38.4c18.1-4.2 36.2-13.3 50.6-25.2c11.1-9.5 27.3-10.1 39.2-1.7c0 0 0 0 0 0C136.7 229.2 165.1 240 192 240c27.5 0 55-10.6 77.5-26.1c11.1-7.9 25.9-7.9 37 0z',
|
|
73
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cn, decorativeAriaProps } from '../../../utils/widgetUtils';
|
|
2
|
+
|
|
3
|
+
export type BackgroundMediaOverlay = 'none' | 'subtle' | 'dark';
|
|
4
|
+
export type BackgroundMediaFallback = 'gradient-mesh' | 'solid';
|
|
5
|
+
|
|
6
|
+
export type BackgroundMediaProps = {
|
|
7
|
+
imageUrl: string;
|
|
8
|
+
imageAlt: string;
|
|
9
|
+
overlay?: BackgroundMediaOverlay;
|
|
10
|
+
fallback?: BackgroundMediaFallback;
|
|
11
|
+
/**
|
|
12
|
+
* CSS `object-position` for the cover-fitted photo. Callers pass a themeable
|
|
13
|
+
* token so a tenant can choose which part of a tall photo survives the crop
|
|
14
|
+
* (BioGarden's hero is a 1440x1920 sky-over-meadow shot that the Figma
|
|
15
|
+
* anchors to the top; centered, the sky is cut away). Omitted = centered.
|
|
16
|
+
*/
|
|
17
|
+
objectPosition?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// The "dark" scrim strength is tenant-tunable: GCA's Figma hero is nearly
|
|
21
|
+
// un-dimmed (see `--pb-hero-overlay` in gca.css); the fallback keeps the
|
|
22
|
+
// original 55% black for tenants that don't set the token.
|
|
23
|
+
const OVERLAY_CLASSES: Record<Exclude<BackgroundMediaOverlay, 'none'>, string> = {
|
|
24
|
+
subtle: 'bg-black/30',
|
|
25
|
+
dark: 'bg-[color:var(--pb-hero-overlay,rgb(0_0_0/0.55))]',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Hoisted: a fresh style object per render would defeat React's prop-equality
|
|
29
|
+
// fast-path and re-trigger style-attribute writes on every editor keystroke.
|
|
30
|
+
const MESH_STYLE = {
|
|
31
|
+
background:
|
|
32
|
+
'radial-gradient(at 18% 28%, #c084fc 0px, transparent 50%), ' +
|
|
33
|
+
'radial-gradient(at 82% 18%, #ec4899 0px, transparent 50%), ' +
|
|
34
|
+
'radial-gradient(at 72% 88%, #6366f1 0px, transparent 50%), ' +
|
|
35
|
+
'#2e1065',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export function BackgroundMedia({
|
|
39
|
+
imageUrl,
|
|
40
|
+
imageAlt,
|
|
41
|
+
overlay = 'none',
|
|
42
|
+
fallback = 'gradient-mesh',
|
|
43
|
+
objectPosition,
|
|
44
|
+
}: BackgroundMediaProps) {
|
|
45
|
+
// data-pb-* attributes are stable selectors for tests and CMS-side analytics.
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
{imageUrl ? (
|
|
49
|
+
<img
|
|
50
|
+
src={imageUrl}
|
|
51
|
+
alt={imageAlt ?? ''}
|
|
52
|
+
{...decorativeAriaProps(imageAlt)}
|
|
53
|
+
loading="lazy"
|
|
54
|
+
className="absolute inset-0 -z-10 w-full h-full object-cover"
|
|
55
|
+
style={objectPosition ? { objectPosition } : undefined}
|
|
56
|
+
/>
|
|
57
|
+
) : fallback === 'gradient-mesh' ? (
|
|
58
|
+
<div
|
|
59
|
+
data-pb-fallback="gradient-mesh"
|
|
60
|
+
className="absolute inset-0 -z-10"
|
|
61
|
+
style={MESH_STYLE}
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<div data-pb-fallback="solid" className="absolute inset-0 -z-10 bg-slate-900" />
|
|
65
|
+
)}
|
|
66
|
+
{overlay !== 'none' && (
|
|
67
|
+
<div
|
|
68
|
+
data-pb-overlay={overlay}
|
|
69
|
+
className={cn('absolute inset-0 -z-10', OVERLAY_CLASSES[overlay])}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cn, decorativeAriaProps } from '../../../utils/widgetUtils';
|
|
2
|
+
|
|
3
|
+
export type ContentMediaAspect = 'square' | 'video' | 'portrait';
|
|
4
|
+
|
|
5
|
+
export type ContentMediaProps = {
|
|
6
|
+
imageUrl: string;
|
|
7
|
+
imageAlt: string;
|
|
8
|
+
aspect?: ContentMediaAspect;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const ASPECT_CLASSES: Record<ContentMediaAspect, string> = {
|
|
12
|
+
square: 'aspect-square',
|
|
13
|
+
video: 'aspect-video',
|
|
14
|
+
portrait: 'aspect-[3/4]',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Hoisted: same rationale as MESH_STYLE in background-media.tsx — keep render
|
|
18
|
+
// referential stability so React doesn't churn the style attribute.
|
|
19
|
+
const DOTTED_STYLE = {
|
|
20
|
+
background:
|
|
21
|
+
'radial-gradient(circle, rgba(99,102,241,0.18) 1px, transparent 1px), ' +
|
|
22
|
+
'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
|
23
|
+
backgroundSize: '16px 16px, 100% 100%',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export function ContentMedia({ imageUrl, imageAlt, aspect = 'video' }: ContentMediaProps) {
|
|
27
|
+
const wrapperCls = cn('w-full rounded-xl overflow-hidden', ASPECT_CLASSES[aspect], 'shadow-xl');
|
|
28
|
+
if (imageUrl) {
|
|
29
|
+
return (
|
|
30
|
+
<div className={wrapperCls}>
|
|
31
|
+
<img
|
|
32
|
+
src={imageUrl}
|
|
33
|
+
alt={imageAlt ?? ''}
|
|
34
|
+
{...decorativeAriaProps(imageAlt)}
|
|
35
|
+
loading="lazy"
|
|
36
|
+
className="w-full h-full object-cover"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
// data-pb-fallback is a stable selector for tests and CMS-side analytics.
|
|
42
|
+
return <div data-pb-fallback="dotted-grid" className={wrapperCls} style={DOTTED_STYLE} />;
|
|
43
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { cn } from '../../../utils/widgetUtils';
|
|
2
|
+
import { renderRichText } from '../_internal/rich-text';
|
|
3
|
+
|
|
4
|
+
export type CTAButtonVariant = 'primary' | 'secondary' | 'inverse-primary' | 'inverse-outline';
|
|
5
|
+
|
|
6
|
+
export type CTAButtonSize = 'md' | 'lg';
|
|
7
|
+
|
|
8
|
+
export type CTAButtonProps = {
|
|
9
|
+
label: string;
|
|
10
|
+
href: string;
|
|
11
|
+
variant: CTAButtonVariant;
|
|
12
|
+
size?: CTAButtonSize;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Shape and type are tenant-tunable: GCA's design system uses pill buttons
|
|
16
|
+
// with SemiBold 17px labels (see `.gca-root` in gca.css); the fallbacks keep
|
|
17
|
+
// the original rounded-md / medium / text-base look for other tenants.
|
|
18
|
+
const BASE =
|
|
19
|
+
'inline-flex items-center justify-center rounded-[var(--pb-button-radius,0.375rem)] font-[number:var(--pb-button-weight,500)] transition-colors ' +
|
|
20
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500';
|
|
21
|
+
|
|
22
|
+
// Padding is tenant-tunable alongside the font size so a tenant whose buttons
|
|
23
|
+
// are a fixed Figma size (BioGarden: 211x54 at 18px) can hit it without every
|
|
24
|
+
// other tenant's buttons changing — the fallbacks are the previous literals.
|
|
25
|
+
const SIZE_CLASSES: Record<CTAButtonSize, string> = {
|
|
26
|
+
md: 'py-[var(--pb-button-py-md,0.625rem)] px-[var(--pb-button-px-md,1.25rem)] text-[length:var(--pb-button-font-size-md,0.875rem)]',
|
|
27
|
+
lg: 'py-[var(--pb-button-py,0.75rem)] px-[var(--pb-button-px,1.5rem)] text-[length:var(--pb-button-font-size,1rem)]',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The two "inverse" variants sit on a photo/dark band (Hero, CallToAction).
|
|
31
|
+
// Their surface is tokenized because tenants disagree on what a primary
|
|
32
|
+
// button on a photo looks like: the default is the solid white pill, while
|
|
33
|
+
// BioGarden's Figma draws it as a transparent white-outlined pill. Every
|
|
34
|
+
// fallback reproduces the previous hardcoded value, so tenants that set none
|
|
35
|
+
// of these tokens render exactly as before.
|
|
36
|
+
const VARIANT_CLASSES: Record<CTAButtonVariant, string> = {
|
|
37
|
+
primary: 'bg-primary-800 hover:bg-primary-900 text-white',
|
|
38
|
+
secondary:
|
|
39
|
+
'border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800',
|
|
40
|
+
'inverse-primary':
|
|
41
|
+
'bg-[var(--pb-button-inverse-bg,#ffffff)] text-[color:var(--pb-button-inverse-fg,var(--color-primary-800))] ' +
|
|
42
|
+
'border-[length:var(--pb-button-inverse-border-width,0px)] border-solid border-[color:var(--pb-button-inverse-border,transparent)] ' +
|
|
43
|
+
'hover:bg-[var(--pb-button-inverse-bg-hover,var(--color-primary-50))]',
|
|
44
|
+
'inverse-outline':
|
|
45
|
+
'bg-[var(--pb-button-outline-bg,rgb(255_255_255/0.2))] border-[length:var(--pb-button-outline-border-width,1px)] border-solid ' +
|
|
46
|
+
'border-[color:var(--pb-button-outline-border,rgb(255_255_255/0.55))] text-white ' +
|
|
47
|
+
'hover:bg-[var(--pb-button-outline-bg-hover,rgb(255_255_255/0.3))]',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function CTAButton({ label, href, variant, size = 'lg' }: CTAButtonProps) {
|
|
51
|
+
if (!label || label.trim() === '') return null;
|
|
52
|
+
return (
|
|
53
|
+
<a href={href || '#'} className={cn(BASE, SIZE_CLASSES[size], VARIANT_CLASSES[variant])}>
|
|
54
|
+
{/* The label is already inside this <a>, so links stay markup-free —
|
|
55
|
+
a nested anchor is invalid HTML and browsers split it. */}
|
|
56
|
+
{renderRichText(label, { allowLinks: false })}
|
|
57
|
+
{/* Trailing arrow, off by default. Tenants flip it on globally via
|
|
58
|
+
`--pb-button-arrow-display` (e.g. inline-block) — no content change.
|
|
59
|
+
Labels that already carry their own arrow (a literal "→", or a "←"
|
|
60
|
+
back link) suppress it, so the global toggle never doubles or
|
|
61
|
+
contradicts an explicit direction. */}
|
|
62
|
+
{!/[←→]/.test(label) && (
|
|
63
|
+
<span aria-hidden="true" className="ml-2 [display:var(--pb-button-arrow-display,none)]">
|
|
64
|
+
→
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</a>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type EmptyPlaceholderProps = {
|
|
2
|
+
label: string;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dashed-border placeholder shown when a media/embed widget has no content
|
|
7
|
+
* to render (no source, no slides, …).
|
|
8
|
+
*/
|
|
9
|
+
export function EmptyPlaceholder({ label }: EmptyPlaceholderProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500">
|
|
12
|
+
{label}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from '../../../utils/widgetUtils';
|
|
2
|
+
import { renderRichText } from '../_internal/rich-text';
|
|
3
|
+
|
|
4
|
+
export type EyebrowTone = 'primary' | 'muted' | 'inverse';
|
|
5
|
+
|
|
6
|
+
export type EyebrowTextProps = {
|
|
7
|
+
children: string;
|
|
8
|
+
tone?: EyebrowTone;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const TONE_CLASSES: Record<EyebrowTone, string> = {
|
|
12
|
+
primary: 'text-primary-800',
|
|
13
|
+
muted: 'text-gray-500 dark:text-gray-400',
|
|
14
|
+
inverse: 'text-white/85',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function EyebrowText({ children, tone = 'primary' }: EyebrowTextProps) {
|
|
18
|
+
// Tolerate undefined at runtime: legacy Puck JSON saved before the eyebrow
|
|
19
|
+
// field existed renders without that prop, and Puck doesn't always merge
|
|
20
|
+
// defaultProps for newly added fields.
|
|
21
|
+
if (!children || children.trim() === '') return null;
|
|
22
|
+
return (
|
|
23
|
+
<p className={cn('text-sm font-semibold uppercase tracking-wider', TONE_CLASSES[tone])}>
|
|
24
|
+
{renderRichText(children)}
|
|
25
|
+
</p>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import { cn } from '../../../utils/widgetUtils';
|
|
3
|
+
import { AccentText } from '../_internal/accent-text';
|
|
4
|
+
|
|
5
|
+
export type HeadingLevel = 'h1' | 'h2' | 'h3';
|
|
6
|
+
export type HeadingSize = 'xl' | 'lg' | 'md';
|
|
7
|
+
export type HeadingTone = 'default' | 'inverse';
|
|
8
|
+
|
|
9
|
+
export type HeadingProps = {
|
|
10
|
+
as: HeadingLevel;
|
|
11
|
+
size: HeadingSize;
|
|
12
|
+
text: string;
|
|
13
|
+
align?: 'left' | 'center';
|
|
14
|
+
tone?: HeadingTone;
|
|
15
|
+
/**
|
|
16
|
+
* Opt-in "accent" treatment for page-hero titles. Tenants can give the hero
|
|
17
|
+
* title a distinct weight/style (e.g. GCA's SemiBold *Italic* H1) via the
|
|
18
|
+
* `--pb-hero-title-*` tokens. Falls back to the normal display weight / an
|
|
19
|
+
* upright style, so tenants that don't set the tokens are unaffected.
|
|
20
|
+
*/
|
|
21
|
+
accent?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Display line-height is tenant-tunable: Mowing's Figma uses 1.2 for every
|
|
25
|
+
// heading level; the fallbacks reproduce the prior tight leading, so tenants
|
|
26
|
+
// that don't set `--pb-heading-leading` (GCA, Recodo) render unchanged.
|
|
27
|
+
const SIZE_CLASSES: Record<HeadingSize, string> = {
|
|
28
|
+
xl: 'leading-[var(--pb-heading-leading,1.1)]',
|
|
29
|
+
lg: 'leading-[var(--pb-heading-leading,1.1)]',
|
|
30
|
+
md: 'leading-[var(--pb-heading-leading,1.25)]',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Font sizes are tenant-themeable via `--pb-heading-*`; the clamp() fallbacks
|
|
34
|
+
// reproduce the previous responsive Tailwind steps, so tenants that don't set
|
|
35
|
+
// the tokens render unchanged. A tenant (e.g. Mowing) can scale the whole
|
|
36
|
+
// display hierarchy up by overriding these tokens in its theme vars.
|
|
37
|
+
const SIZE_FONT: Record<HeadingSize, string> = {
|
|
38
|
+
xl: 'var(--pb-heading-xl, clamp(2.25rem, 1.4rem + 3.6vw, 3.75rem))',
|
|
39
|
+
lg: 'var(--pb-heading-lg, clamp(1.875rem, 1.3rem + 2.5vw, 3rem))',
|
|
40
|
+
md: 'var(--pb-heading-md, clamp(1.5rem, 1.2rem + 1.4vw, 1.875rem))',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// `inverse` keeps white on dark/colored backgrounds; `default` takes the
|
|
44
|
+
// per-tenant heading color from the --pb-* tokens via `style`.
|
|
45
|
+
const TONE_CLASSES: Record<HeadingTone, string> = {
|
|
46
|
+
default: '',
|
|
47
|
+
inverse: 'text-white',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Display weight/tracking/font come from per-tenant page-builder tokens
|
|
51
|
+
// (Recodo defaults in styles.css `:root`, GCA overrides in `.gca-root`).
|
|
52
|
+
const DISPLAY_STYLE: CSSProperties = {
|
|
53
|
+
fontWeight: 'var(--pb-display-weight)' as CSSProperties['fontWeight'],
|
|
54
|
+
letterSpacing: 'var(--pb-display-tracking)',
|
|
55
|
+
fontFamily: 'var(--pb-display-font)',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Hero-title "accent" overrides: a distinct weight + (optional) italic style,
|
|
59
|
+
// each falling back so non-GCA tenants render an upright display-weight title.
|
|
60
|
+
const ACCENT_STYLE: CSSProperties = {
|
|
61
|
+
fontWeight:
|
|
62
|
+
'var(--pb-hero-title-weight, var(--pb-display-weight))' as CSSProperties['fontWeight'],
|
|
63
|
+
fontStyle: 'var(--pb-hero-title-style, normal)' as CSSProperties['fontStyle'],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function Heading({ as, size, text, align, tone = 'default', accent = false }: HeadingProps) {
|
|
67
|
+
if (!text || text.trim() === '') return null;
|
|
68
|
+
const Tag = as;
|
|
69
|
+
// Hero titles (accent) may take a dedicated size token so a tenant can make
|
|
70
|
+
// its page-hero H1 larger than section headings that share the same `size`
|
|
71
|
+
// (e.g. Mowing: hero 64px while SectionIntro `lg` stays 48px). Falls back to
|
|
72
|
+
// the normal size token, so tenants that don't set it render unchanged.
|
|
73
|
+
const fontSize = accent ? `var(--pb-hero-title-size, ${SIZE_FONT[size]})` : SIZE_FONT[size];
|
|
74
|
+
const base: CSSProperties = {
|
|
75
|
+
...(accent ? { ...DISPLAY_STYLE, ...ACCENT_STYLE } : DISPLAY_STYLE),
|
|
76
|
+
fontSize,
|
|
77
|
+
};
|
|
78
|
+
const style: CSSProperties =
|
|
79
|
+
tone === 'inverse' ? base : { ...base, color: 'var(--pb-heading-color)' };
|
|
80
|
+
return (
|
|
81
|
+
<Tag
|
|
82
|
+
className={cn(SIZE_CLASSES[size], TONE_CLASSES[tone], align === 'center' && 'text-center')}
|
|
83
|
+
style={style}
|
|
84
|
+
>
|
|
85
|
+
<AccentText text={text} />
|
|
86
|
+
</Tag>
|
|
87
|
+
);
|
|
88
|
+
}
|