@windstream/react-shared-components 0.2.30 → 0.2.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,388 @@
1
+ import { useState, type ComponentProps } from "react";
2
+ import { AddressInputBanner } from "./index";
3
+
4
+ import { Button } from "@shared/contentful/blocks/button";
5
+ import { DocsPage } from "@shared/stories/DocsTemplate";
6
+ import type { Meta, StoryObj } from "@storybook/react-vite";
7
+
8
+ // Sample address suggestions shown once the user types 3+ characters.
9
+ const ADDRESS_SUGGESTIONS = [
10
+ "123 Dale Springs, Apt B, Binsport, NC, 17032",
11
+ "123 Dale Springs, Apt C, Binsport, NC, 17032",
12
+ "123 Dale Avenue, Binsport, NC, 17033",
13
+ ];
14
+
15
+ type AddressBarProps = {
16
+ buttonVariant?: "primary_brand" | "primary_inverse" | "secondary";
17
+ ctaLabel?: string;
18
+ desktopPrompt?: string;
19
+ mobilePrompt?: string;
20
+ placeholder?: string;
21
+ promptTextClass?: string;
22
+ };
23
+
24
+ // Shared address bar (title + location input + check-plans button) used across
25
+ // the stories. The button variant is configurable so themed variants (e.g.
26
+ // Yellow) can render an inverse/navy CTA.
27
+ const AddressBar = ({
28
+ buttonVariant = "primary_brand",
29
+ ctaLabel = "Check plans",
30
+ desktopPrompt = "To see plans for your address, check availability",
31
+ mobilePrompt = "Show me the best offers in my area",
32
+ placeholder = "123 Dale Springs, Apt B, Binsport, NC, 17032",
33
+ promptTextClass = "text-text",
34
+ }: AddressBarProps) => {
35
+ const [query, setQuery] = useState("");
36
+ const showResults = query.trim().length >= 3;
37
+
38
+ // Mobile "Go" button reuses the BrandButton color tokens but is rendered as a
39
+ // plain 56x56 button so it can honor the fixed Figma width (the shared Button
40
+ // has a baked-in 60px horizontal padding that cannot be overridden here).
41
+ const goVariantClass =
42
+ buttonVariant === "primary_inverse"
43
+ ? "bg-bg-fill-inverse text-text-inverse"
44
+ : buttonVariant === "secondary"
45
+ ? "border border-border-secondary-on-bg-fill bg-bg-fill-secondary text-text"
46
+ : "bg-bg-fill-brand text-text-brand-on-bg-fill";
47
+
48
+ return (
49
+ <div className="flex w-full max-w-[1440px] flex-col items-center justify-center gap-4 py-[10px] lg:flex-row lg:gap-6">
50
+ <span
51
+ className={`label1 w-full whitespace-nowrap text-center text-base lg:hidden ${promptTextClass}`}
52
+ >
53
+ {mobilePrompt}
54
+ </span>
55
+ <span
56
+ className={`label1 hidden lg:block lg:w-auto lg:shrink-0 lg:whitespace-nowrap lg:text-right ${promptTextClass}`}
57
+ >
58
+ {desktopPrompt}
59
+ </span>
60
+ <div className="flex w-full items-stretch gap-2 lg:contents">
61
+ <div className="relative flex min-w-0 flex-1 flex-col items-start justify-center gap-1 lg:max-w-[480px] lg:flex-1">
62
+ <div className="flex h-14 w-full items-center gap-2 self-stretch rounded-input border border-input-border bg-input-bg-surface px-4 focus-within:border-2 focus-within:border-input-border-selected lg:h-[60px]">
63
+ <svg
64
+ xmlns="http://www.w3.org/2000/svg"
65
+ width="20"
66
+ height="20"
67
+ viewBox="0 0 24 24"
68
+ fill="#16a34a"
69
+ aria-hidden="true"
70
+ className="shrink-0"
71
+ >
72
+ <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5z" />
73
+ </svg>
74
+ <input
75
+ type="text"
76
+ value={query}
77
+ onChange={event => setQuery(event.target.value)}
78
+ placeholder={placeholder}
79
+ className="body2 w-full min-w-0 bg-transparent text-text outline-none placeholder:text-input-text-placeholder"
80
+ />
81
+ </div>
82
+ {showResults && (
83
+ <div className="absolute left-0 top-full z-10 mt-1 flex w-full flex-col items-center rounded-lg bg-bg-surface shadow-[1px_1px_16px_0_rgba(0,0,0,0.12),-1px_-1px_16px_0_#FFF]">
84
+ {ADDRESS_SUGGESTIONS.map(address => (
85
+ <button
86
+ key={address}
87
+ type="button"
88
+ className="body2 flex w-full items-center gap-2 px-4 py-3 text-left text-text hover:bg-bg-surface-secondary"
89
+ >
90
+ <svg
91
+ xmlns="http://www.w3.org/2000/svg"
92
+ width="20"
93
+ height="20"
94
+ viewBox="0 0 24 24"
95
+ fill="#757575"
96
+ aria-hidden="true"
97
+ className="shrink-0"
98
+ >
99
+ <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5z" />
100
+ </svg>
101
+ <span className="truncate">{address}</span>
102
+ </button>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </div>
107
+ <button
108
+ type="button"
109
+ className={`flex h-14 w-14 shrink-0 items-center justify-center gap-2 rounded-button text-lg font-black lg:hidden ${goVariantClass}`}
110
+ >
111
+ Go
112
+ </button>
113
+ <div className="hidden lg:block">
114
+ <Button
115
+ showButtonAs="solid"
116
+ buttonVariant={buttonVariant}
117
+ buttonLabel={ctaLabel}
118
+ size={{ base: "large" }}
119
+ />
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ };
125
+
126
+ // Story args extend the banner props with the editable mock fields so the
127
+ // address bar text and CTA can be controlled from the Storybook controls
128
+ // instead of being hardcoded.
129
+ type StoryArgs = ComponentProps<typeof AddressInputBanner> & {
130
+ ctaLabel?: string;
131
+ ctaButtonVariant?: "primary_brand" | "primary_inverse" | "secondary";
132
+ mobilePrompt?: string;
133
+ placeholder?: string;
134
+ };
135
+
136
+ const meta: Meta<StoryArgs> = {
137
+ title: "Contentful Blocks/AddressInputBanner",
138
+ component: AddressInputBanner,
139
+ tags: ["autodocs"],
140
+ // Provide a tall, scrollable canvas so the sticky banner's `navHeight` offset
141
+ // is observable — CSS `position: sticky` only applies its `top` value while
142
+ // scrolling within a container taller than the viewport.
143
+ decorators: [
144
+ Story => (
145
+ <div className="min-h-[150vh]">
146
+ <Story />
147
+ <p className="body2 p-6 text-text">
148
+ Scroll to see the banner stick at the top (offset by navHeight).
149
+ </p>
150
+ </div>
151
+ ),
152
+ ],
153
+ parameters: {
154
+ layout: "fullscreen",
155
+ docs: {
156
+ page: DocsPage,
157
+ description: {
158
+ component:
159
+ "A sticky address input banner component that supports multiple themed variants and an optional CTA. It can be shown/hidden dynamically and adjusts positioning based on the navigation height.",
160
+ },
161
+ },
162
+ },
163
+ argTypes: {
164
+ title: {
165
+ control: { type: "text" },
166
+ description: "Title shown as the desktop heading next to the input",
167
+ },
168
+ variant: {
169
+ control: { type: "select" },
170
+ options: ["yellow", "white", "navy", "green"],
171
+ description: "Themed color variant of the banner",
172
+ },
173
+ navHeight: {
174
+ control: { type: "number" },
175
+ description: "Navigation height used to offset the sticky position",
176
+ },
177
+ isVisible: {
178
+ control: { type: "boolean" },
179
+ description: "Whether the banner is rendered",
180
+ },
181
+ ctaLabel: {
182
+ control: { type: "text" },
183
+ description: "CTA button label",
184
+ },
185
+ ctaButtonVariant: {
186
+ control: { type: "select" },
187
+ options: ["primary_brand", "primary_inverse", "secondary"],
188
+ description: "CTA button variant",
189
+ },
190
+ mobilePrompt: {
191
+ control: { type: "text" },
192
+ description: "Prompt text shown on mobile",
193
+ },
194
+ placeholder: {
195
+ control: { type: "text" },
196
+ description: "Address input placeholder text",
197
+ },
198
+ entryName: {
199
+ table: { disable: true },
200
+ },
201
+ cta: {
202
+ table: { disable: true },
203
+ },
204
+ },
205
+ args: {
206
+ title: "To see plans for your address, check availability",
207
+ variant: "yellow",
208
+ isVisible: true,
209
+ navHeight: 0,
210
+ ctaLabel: "Check plans",
211
+ mobilePrompt: "Show me the best offers in my area",
212
+ placeholder: "123 Dale Springs, Apt B, Binsport, NC, 17032",
213
+ },
214
+ render: ({
215
+ title,
216
+ ctaLabel,
217
+ ctaButtonVariant,
218
+ mobilePrompt,
219
+ placeholder,
220
+ renderCheckPlans,
221
+ cta,
222
+ ...args
223
+ }) => {
224
+ // Dark-background variants need light prompt text for contrast.
225
+ const promptTextClass =
226
+ args.variant === "navy" || args.variant === "green"
227
+ ? "text-white"
228
+ : "text-text";
229
+
230
+ // Derive the CTA button variant from the banner variant so it adapts when
231
+ // the variant control changes. An explicit ctaButtonVariant still overrides.
232
+ const effectiveButtonVariant =
233
+ ctaButtonVariant ??
234
+ (args.variant === "yellow" || args.variant === "green"
235
+ ? "primary_inverse"
236
+ : "primary_brand");
237
+
238
+ // Keep the cta's buttonVariant in sync with the effective variant so the
239
+ // ctaButtonVariant control also drives stories that supply their own cta
240
+ // and renderCheckPlans (e.g. WithCta).
241
+ const effectiveCta = cta
242
+ ? { ...cta, buttonVariant: effectiveButtonVariant }
243
+ : cta;
244
+
245
+ return (
246
+ <AddressInputBanner
247
+ {...args}
248
+ cta={effectiveCta}
249
+ title=""
250
+ renderCheckPlans={
251
+ renderCheckPlans ??
252
+ (() => (
253
+ <AddressBar
254
+ buttonVariant={effectiveButtonVariant}
255
+ ctaLabel={ctaLabel}
256
+ desktopPrompt={title}
257
+ mobilePrompt={mobilePrompt}
258
+ placeholder={placeholder}
259
+ promptTextClass={promptTextClass}
260
+ />
261
+ ))
262
+ }
263
+ />
264
+ );
265
+ },
266
+ };
267
+
268
+ export default meta;
269
+ type Story = StoryObj<typeof meta>;
270
+
271
+ // Default banner using all the default entries from meta args
272
+ export const Default: Story = {
273
+ args: {},
274
+ parameters: {
275
+ docs: {
276
+ description: {
277
+ story:
278
+ "Default AddressInputBanner rendered with all the default entries (yellow variant, visible, with the address bar CTA).",
279
+ },
280
+ },
281
+ },
282
+ };
283
+
284
+ // Yellow variant
285
+ export const Yellow: Story = {
286
+ args: {
287
+ variant: "yellow",
288
+ },
289
+ };
290
+
291
+ // White variant
292
+ export const White: Story = {
293
+ args: {
294
+ variant: "white",
295
+ },
296
+ };
297
+
298
+ // Navy variant
299
+ export const Navy: Story = {
300
+ args: {
301
+ variant: "navy",
302
+ },
303
+ };
304
+
305
+ // Green variant
306
+ export const Green: Story = {
307
+ args: {
308
+ variant: "green",
309
+ },
310
+ };
311
+
312
+ // Hidden banner renders nothing
313
+ export const Hidden: Story = {
314
+ args: {
315
+ isVisible: false,
316
+ },
317
+ parameters: {
318
+ docs: {
319
+ description: {
320
+ story:
321
+ "When isVisible is false, the banner renders nothing to the DOM.",
322
+ },
323
+ },
324
+ },
325
+ };
326
+
327
+ // Banner supplied with an explicit cta prop. It falls back to the default
328
+ // address bar render, so it looks consistent with the other variant stories
329
+ // while still exercising the cta overrides.
330
+ export const WithCta: Story = {
331
+ args: {
332
+ cta: {
333
+ buttonLabel: "Check plans",
334
+ buttonVariant: "primary_brand",
335
+ showButtonAs: "solid",
336
+ },
337
+ },
338
+ parameters: {
339
+ docs: {
340
+ description: {
341
+ story:
342
+ "AddressInputBanner rendered with an explicit `cta` prop. It uses the default address bar render, so it looks consistent with the other variant stories while still exercising the `cta` overrides.",
343
+ },
344
+ },
345
+ },
346
+ };
347
+
348
+ // Demonstrates the sticky positioning offset with a non-zero navHeight value.
349
+ // A mock fixed nav bar is layered on top of the shared scrollable canvas so the
350
+ // banner can be seen sticking directly below it once the page scrolls.
351
+ export const CustomNavHeight: Story = {
352
+ args: {
353
+ navHeight: 80,
354
+ },
355
+ decorators: [
356
+ (Story, context) => {
357
+ // Keep the mock nav height/label in sync with the navHeight control.
358
+ const navHeight = (context.args.navHeight as number | undefined) ?? 80;
359
+ return (
360
+ <div className="relative min-h-[150vh]">
361
+ {/* Mock fixed navigation bar that matches the navHeight control */}
362
+ <div
363
+ style={{ height: navHeight }}
364
+ className="fixed left-0 right-0 top-0 z-[90] flex w-full items-center justify-center overflow-hidden whitespace-nowrap bg-bg-fill-inverse text-xs text-text-inverse"
365
+ >
366
+ {`Mock Navigation (${navHeight}px)`}
367
+ </div>
368
+ {/* Spacer so content starts below the fixed nav */}
369
+ <div style={{ height: navHeight }} />
370
+ <Story />
371
+ {/* Tall content in the SAME container as the banner so it has room to
372
+ stick while scrolling. */}
373
+ <div className="flex h-[120vh] items-start justify-center p-6 text-text">
374
+ {`Scroll — the banner stays pinned ${navHeight}px from the top, below the nav.`}
375
+ </div>
376
+ </div>
377
+ );
378
+ },
379
+ ],
380
+ parameters: {
381
+ docs: {
382
+ description: {
383
+ story:
384
+ "Demonstrates the sticky positioning with a non-zero `navHeight` (80px), which offsets the banner's `top` value so it sticks below a fixed navigation bar. Scroll the canvas to see the offset take effect.",
385
+ },
386
+ },
387
+ },
388
+ };
@@ -16,6 +16,7 @@ export function BlogGridBase({
16
16
  onCategoryChange,
17
17
  onPageChange,
18
18
  getPageHref,
19
+ asList = false,
19
20
  imageComponent,
20
21
  }: BlogGridBaseProps) {
21
22
  function handleCategoryChange(value: unknown) {
@@ -66,9 +67,15 @@ export function BlogGridBase({
66
67
  )}
67
68
  </div>
68
69
 
69
- {/* Articles grid */}
70
+ {/* Articles grid / list */}
70
71
  {paginatedArticles.length > 0 ? (
71
- <div className="mx-auto grid max-w-[1200px] grid-cols-1 gap-6 px-5 pb-16 sm:grid-cols-2 lg:grid-cols-3">
72
+ <div
73
+ className={
74
+ asList
75
+ ? "mx-auto flex max-w-[1200px] flex-col gap-1 px-5 pb-16"
76
+ : "mx-auto grid max-w-[1200px] grid-cols-1 gap-6 px-5 pb-16 sm:grid-cols-2 lg:grid-cols-3"
77
+ }
78
+ >
72
79
  {paginatedArticles.map((article, index) => {
73
80
  const href = article.slug.startsWith("/")
74
81
  ? article.slug
@@ -95,6 +102,7 @@ export function BlogGridBase({
95
102
  category={article.category}
96
103
  image={coverImage}
97
104
  imageComponent={imageComponent}
105
+ asGrid={!asList}
98
106
  index={index}
99
107
  />
100
108
  );
@@ -27,6 +27,8 @@ export interface BlogGridBaseProps {
27
27
  onCategoryChange?: (category: BlogCategoryOption) => void;
28
28
  onPageChange?: (page: number) => void;
29
29
  getPageHref?: (page: number) => string;
30
+ /** When true, render cards in a single-column list instead of a grid. */
31
+ asList?: boolean;
30
32
  /** Pass next/image's Image component here to enable image optimization */
31
33
  imageComponent?: React.ComponentType<BlogCardImageProps>;
32
34
  }
@@ -27,57 +27,66 @@ export const BlogCard: React.FC<BlogCardProps> = ({
27
27
  }: BlogCardProps) => {
28
28
  const parentClassName = asGrid
29
29
  ? "flex h-full flex-col overflow-hidden rounded-card-lg bg-white shadow-drop transition-all duration-200 hover:-translate-y-0.5 hover:shadow-cardDrop"
30
- : `callout-card w-full flex h-full flex-col overflow-hidden rounded-card-lg bg-white shadow-drop transition-all duration-200 hover:-translate-y-0.5 hover:shadow-cardDrop`;
30
+ : `callout-card w-full flex h-full flex-col overflow-hidden`;
31
31
 
32
32
  return (
33
33
  <article
34
34
  className={parentClassName}
35
35
  data-section-type={"blog-card"}
36
36
  data-section-index={index}
37
+ data-view-mode={asGrid ? "grid" : "list"}
37
38
  >
38
- {/* Image */}
39
- <Link href={href} tabIndex={-1} aria-hidden="true" className="block">
40
- <div className="h-[232px] w-full flex-shrink-0 overflow-hidden bg-gray-100">
41
- {image ? (
42
- ImageComponent ? (
43
- <ImageComponent
44
- src={image.src}
45
- alt={image.alt}
46
- width={image.width}
47
- height={image.height}
48
- className="h-full w-full object-cover transition-transform duration-300 hover:scale-[1.03]"
49
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
50
- />
39
+ {/* Image (hidden in list view) */}
40
+ {asGrid && (
41
+ <Link href={href} tabIndex={-1} aria-hidden="true" className="block">
42
+ <div className="h-[232px] w-full flex-shrink-0 overflow-hidden bg-gray-100">
43
+ {image ? (
44
+ ImageComponent ? (
45
+ <ImageComponent
46
+ src={image.src}
47
+ alt={image.alt}
48
+ width={image.width}
49
+ height={image.height}
50
+ className="h-full w-full object-cover transition-transform duration-300 hover:scale-[1.03]"
51
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
52
+ />
53
+ ) : (
54
+ <img
55
+ src={image.src}
56
+ alt={image.alt}
57
+ width={image.width}
58
+ height={image.height}
59
+ loading="lazy"
60
+ decoding="async"
61
+ className="h-full w-full object-cover transition-transform duration-300 hover:scale-[1.03]"
62
+ />
63
+ )
51
64
  ) : (
52
- <img
53
- src={image.src}
54
- alt={image.alt}
55
- width={image.width}
56
- height={image.height}
57
- loading="lazy"
58
- decoding="async"
59
- className="h-full w-full object-cover transition-transform duration-300 hover:scale-[1.03]"
65
+ <div
66
+ className="h-full w-full bg-gradient-to-br from-gray-200 to-gray-100"
67
+ aria-hidden="true"
60
68
  />
61
- )
62
- ) : (
63
- <div
64
- className="h-full w-full bg-gradient-to-br from-gray-200 to-gray-100"
65
- aria-hidden="true"
66
- />
67
- )}
68
- </div>
69
- </Link>
69
+ )}
70
+ </div>
71
+ </Link>
72
+ )}
70
73
 
71
74
  {/* Body */}
72
- <div className="flex flex-1 flex-col gap-5 p-6 md:p-8">
75
+ <div
76
+ className={`flex flex-1 flex-col ${
77
+ asGrid ? "gap-5 p-6 md:p-8" : "gap-1 border-b p-3 md:p-4"
78
+ }`}
79
+ >
73
80
  {/* Meta: category + date */}
74
81
  <div className="flex items-center gap-2 text-[13px]">
75
- <span className="body2 text-text-brand">{category}</span>
82
+ {asGrid && <span className="body2 text-text-brand">{category}</span>}
76
83
  {date && (
77
84
  <>
78
- <span className="footnote text-text" aria-hidden="true">
79
-
80
- </span>
85
+ {asGrid && (
86
+ <span className="footnote text-text" aria-hidden="true">
87
+
88
+ </span>
89
+ )}
81
90
  <time className="body2 text-text">{date}</time>
82
91
  </>
83
92
  )}
@@ -100,25 +109,27 @@ export const BlogCard: React.FC<BlogCardProps> = ({
100
109
  </Text>
101
110
  )}
102
111
 
103
- {/* Read more */}
104
- <Link
105
- href={href}
106
- className="group mt-auto inline-flex items-center justify-start gap-2 pt-3 text-sm text-text-brand no-underline"
107
- aria-label={`${readMoreText} about ${title || "this article"}`}
108
- >
109
- <Text
112
+ {/* Read more (hidden in list view) */}
113
+ {asGrid && (
114
+ <Link
115
+ href={href}
116
+ className="group mt-auto inline-flex items-center justify-start gap-2 pt-3 text-sm text-text-brand no-underline"
110
117
  aria-label={`${readMoreText} about ${title || "this article"}`}
111
- className="label1 text-nowrap"
112
118
  >
113
- {readMoreText}
114
- </Text>
115
- <MaterialIcon
116
- name="expand_circle_right"
117
- fill={1}
118
- size={24}
119
- weight="200"
120
- />
121
- </Link>
119
+ <Text
120
+ aria-label={`${readMoreText} about ${title || "this article"}`}
121
+ className="label1 text-nowrap"
122
+ >
123
+ {readMoreText}
124
+ </Text>
125
+ <MaterialIcon
126
+ name="expand_circle_right"
127
+ fill={1}
128
+ size={24}
129
+ weight="200"
130
+ />
131
+ </Link>
132
+ )}
122
133
  </div>
123
134
  </article>
124
135
  );
@@ -33,7 +33,7 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
33
33
  ctaButtonText,
34
34
  ctaText,
35
35
  caption,
36
- backgroundColor = "green",
36
+ backgroundColor = "white",
37
37
  successFeedback,
38
38
  onSubmit,
39
39
  themeColor,
@@ -48,7 +48,7 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
48
48
  const backgroundColorClasses: Record<string, string> = {
49
49
  green: "bg-bg-fill-brand",
50
50
  white: "bg-bg",
51
- gray90: "bg-[#464646]",
51
+ gray90: "bg-[var(--color-text-secondary)]",
52
52
  };
53
53
 
54
54
  const themeColorClasses: Record<string, string> = {
@@ -59,10 +59,13 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
59
59
  white: "bg-bg",
60
60
  };
61
61
 
62
- const normalizedBg = (backgroundColor as string)?.toLowerCase?.() || "white";
62
+ const normalizedBg =
63
+ ((backgroundColor as string) || "").trim().toLowerCase() || "white";
63
64
  const outerBgClass = backgroundColorClasses[normalizedBg as keyof typeof backgroundColorClasses] || backgroundColorClasses.white;
65
+ const captionTextColorClass = normalizedBg === "white" ? "text-text" : "text-white";
64
66
 
65
- const normalizedTheme = (themeColor as string)?.toLowerCase?.() || "green";
67
+ const normalizedTheme =
68
+ ((themeColor as string) || "").trim().toLowerCase() || "green";
66
69
  const containerBgClass = themeColorClasses[normalizedTheme as keyof typeof themeColorClasses] || themeColorClasses.green;
67
70
 
68
71
  const darkThemeColors = ["green", "blue", "purple"];
@@ -164,7 +167,7 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
164
167
  </div>
165
168
  </div>
166
169
  {caption && (
167
- <div className="mx-auto max-w-[1200px] px-6 md:px-8">{caption}</div>
170
+ <div className={`mx-auto max-w-[1200px] px-6 md:px-8 ${captionTextColorClass}`}>{caption}</div>
168
171
  )}
169
172
  </section>
170
173
  );
@@ -16,6 +16,14 @@ import { MaterialIcon } from "@shared/components/material-icon";
16
16
  import { Text } from "@shared/components/text";
17
17
  import { toDocument } from "@shared/utils/contentful/to-document";
18
18
 
19
+ const isEmptyParagraph = (node: any): boolean =>
20
+ Array.isArray(node?.content) &&
21
+ node.content.every(
22
+ (child: any) =>
23
+ child?.nodeType === "text" &&
24
+ (!child.value || child.value.trim() === "")
25
+ );
26
+
19
27
  const defaultOptions: Options = {
20
28
  renderMark: {
21
29
  [MARKS.BOLD]: text => <strong className="font-black">{text}</strong>,
@@ -24,8 +32,10 @@ const defaultOptions: Options = {
24
32
  [MARKS.CODE]: text => <code>{text}</code>,
25
33
  },
26
34
  renderNode: {
27
- [BLOCKS.PARAGRAPH]: (_node, children) => (
28
- <div className="body3 mb-4">{children}</div>
35
+ [BLOCKS.PARAGRAPH]: (node, children) => (
36
+ <div className="body3 mb-4 whitespace-pre-line">
37
+ {isEmptyParagraph(node) ? "\n" : children}
38
+ </div>
29
39
  ),
30
40
  [BLOCKS.HEADING_1]: (node, children) => {
31
41
  return (
@@ -156,8 +166,10 @@ export function renderContentfulRichText(
156
166
  ...defaultOptions.renderNode,
157
167
  ...options?.renderNode,
158
168
  // Logic for links based on the isTargetBlank flag
159
- [BLOCKS.PARAGRAPH]: (_node, children) => (
160
- <div className={className}>{children}</div>
169
+ [BLOCKS.PARAGRAPH]: (node, children) => (
170
+ <div className={[className, "whitespace-pre-line"].filter(Boolean).join(" ")}>
171
+ {isEmptyParagraph(node) ? "\n" : children}
172
+ </div>
161
173
  ),
162
174
  [INLINES.HYPERLINK]: (node, children) => {
163
175
  const url = (node as any)?.data?.uri as string;