@akinon/pz-theme 2.0.96-rc.0 → 2.0.96

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.
@@ -0,0 +1,54 @@
1
+ import { getResponsiveValue } from '../utils';
2
+
3
+ /**
4
+ * The one "nothing was stored" answer, shared by every call.
5
+ *
6
+ * Identity matters more than it looks: a renderer that puts the result in a
7
+ * `useMemo`/`useEffect` dependency list would re-run on every render if each
8
+ * miss produced a fresh `[]`, and the churn is invisible until something
9
+ * expensive (a fetch, a measurement) hangs off it. Frozen so a caller that
10
+ * mutates the result fails loudly here instead of poisoning the next block
11
+ * that reads an empty list.
12
+ */
13
+ export const EMPTY_RESPONSIVE_ARRAY: never[] = Object.freeze([]) as never[];
14
+
15
+ /**
16
+ * Reads a stored list that may have been written three different ways.
17
+ *
18
+ * The repeatable panel writes properties flat, so a list normally arrives as a
19
+ * bare array. A descriptor whose `getDefaultValue` seeded per-breakpoint
20
+ * buckets, or a merchant who authored the list at one breakpoint, makes it
21
+ * arrive as `{desktop: [...], mobile: [...]}` instead. Anything else — a
22
+ * string, a number, an object of something other than lists — is "no list".
23
+ *
24
+ * The bucketed case is handed to `getResponsiveValue` rather than walked here:
25
+ * the tablet -> mobile -> desktop chain is the same chain the emitted
26
+ * stylesheet cascades through, and a second copy of it would be a second thing
27
+ * to keep in step with `BREAKPOINT_FALLBACKS`.
28
+ */
29
+ export const readResponsiveArray = <T>(
30
+ value: unknown,
31
+ breakpoint = 'desktop'
32
+ ): T[] => {
33
+ // Checked before the object branch: an array IS an object, and
34
+ // `getResponsiveValue` deliberately rejects arrays as malformed responsive
35
+ // maps, so asking it first would throw away every flat list.
36
+ if (Array.isArray(value)) return value as T[];
37
+
38
+ if (value && typeof value === 'object') {
39
+ const resolved = getResponsiveValue(
40
+ value,
41
+ breakpoint,
42
+ EMPTY_RESPONSIVE_ARRAY
43
+ );
44
+ return Array.isArray(resolved) ? (resolved as T[]) : EMPTY_RESPONSIVE_ARRAY;
45
+ }
46
+
47
+ return EMPTY_RESPONSIVE_ARRAY;
48
+ };
49
+
50
+ /** True when the stored value yields at least one entry at this breakpoint. */
51
+ export const hasResponsiveArray = (
52
+ value: unknown,
53
+ breakpoint = 'desktop'
54
+ ): boolean => readResponsiveArray(value, breakpoint).length > 0;
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Stroke glyphs on the shared 24x24 grid the other chrome blocks draw on, so a
5
+ * single `stroke: currentColor` inherits the header colour. The keys are the
6
+ * platform values the editor's social icon picker stores.
7
+ *
8
+ * This table lives beside the renderers rather than inside `social-links-block`
9
+ * because more than one block draws a platform mark now. There is exactly one
10
+ * copy: a second table would drift the moment the editor's picker gained a
11
+ * platform, and the mismatch shows up as a silently wrong logo, not an error.
12
+ */
13
+ export const SOCIAL_GLYPHS: Record<string, React.ReactNode> = {
14
+ facebook: (
15
+ <path d="M14 8.5V7a1.5 1.5 0 0 1 1.5-1.5H17V3h-2A4 4 0 0 0 11 7v1.5H9V11h2v10h3V11h2.2l.4-2.5H14z" />
16
+ ),
17
+ instagram: (
18
+ <>
19
+ <rect x="3" y="3" width="18" height="18" rx="5" />
20
+ <circle cx="12" cy="12" r="4" />
21
+ <circle cx="17.4" cy="6.6" r="0.6" />
22
+ </>
23
+ ),
24
+ twitter: <path d="M4 4l16 16M20 4L4 20" />,
25
+ youtube: (
26
+ <>
27
+ <rect x="2.5" y="6" width="19" height="12" rx="4" />
28
+ <path d="M10 9.5l5 2.5-5 2.5z" />
29
+ </>
30
+ ),
31
+ linkedin: (
32
+ <>
33
+ <path d="M6.5 9.5V20" />
34
+ <circle cx="6.5" cy="5" r="1.1" />
35
+ <path d="M11 20v-6.5a3.5 3.5 0 0 1 7 0V20" />
36
+ </>
37
+ ),
38
+ tiktok: (
39
+ <>
40
+ <path d="M14 4v11a4 4 0 1 1-4-4" />
41
+ <path d="M14 4c.5 2.6 2.2 4.2 5 4.4" />
42
+ </>
43
+ ),
44
+ pinterest: (
45
+ <>
46
+ <circle cx="12" cy="12" r="9" />
47
+ <path d="M9.5 21l2.2-8.5" />
48
+ <path d="M9 11a3 3 0 1 1 5 2.2" />
49
+ </>
50
+ ),
51
+ whatsapp: (
52
+ <>
53
+ <path d="M3.5 20.5l1.4-4.2A8.2 8.2 0 1 1 8 19.4z" />
54
+ <path d="M9 9.5c0 3 2.5 5.5 5.5 5.5l1-1.6-2-1-1 .9a5 5 0 0 1-1.8-1.8l.9-1-1-2z" />
55
+ </>
56
+ ),
57
+ telegram: <path d="M21 4.5L2.8 11.3l5 1.8L18 7l-7.5 7.4.3 5 2.7-3.4 4 3z" />,
58
+ discord: (
59
+ <>
60
+ <path d="M9 18c-2 0-4-1-4.5-2.5C4 12 5 8.5 6.5 7A11 11 0 0 1 12 5.8 11 11 0 0 1 17.5 7C19 8.5 20 12 19.5 15.5 19 17 17 18 15 18" />
61
+ <circle cx="9.5" cy="13" r="1.1" />
62
+ <circle cx="14.5" cy="13" r="1.1" />
63
+ </>
64
+ ),
65
+ snapchat: (
66
+ <path d="M12 4c2.8 0 4.3 2 4.3 4.6v2c.9.5 1.8.4 2.4.9-.8 1.4-1.9 1.5-1.9 2.3 0 1 1.7 2 2.7 2.2-.9 1.2-2.8 1.2-3.7 1.9-.9.7-1.5 1.1-3.8 1.1s-2.9-.4-3.8-1.1c-.9-.7-2.8-.7-3.7-1.9 1-.2 2.7-1.2 2.7-2.2 0-.8-1.1-.9-1.9-2.3.6-.5 1.5-.4 2.4-.9v-2C7.7 6 9.2 4 12 4z" />
67
+ ),
68
+ reddit: (
69
+ <>
70
+ <circle cx="12" cy="13" r="8" />
71
+ <circle cx="9.5" cy="12.5" r="1" />
72
+ <circle cx="14.5" cy="12.5" r="1" />
73
+ <path d="M9 16c1.8 1.2 4.2 1.2 6 0" />
74
+ <path d="M15 5.6l.7 3.4" />
75
+ <circle cx="16.4" cy="4.6" r="1.1" />
76
+ </>
77
+ ),
78
+ twitch: (
79
+ <>
80
+ <path d="M4.5 4h15v9.5L15 18h-3l-3 3H7.5v-3h-3z" />
81
+ <path d="M11 8v4M15 8v4" />
82
+ </>
83
+ ),
84
+ spotify: (
85
+ <>
86
+ <circle cx="12" cy="12" r="9" />
87
+ <path d="M7.5 9.5c3-1 6.5-.6 9 1" />
88
+ <path d="M8.2 13c2.4-.8 5.2-.5 7.3.9" />
89
+ <path d="M9 16.2c1.8-.6 3.9-.4 5.5.7" />
90
+ </>
91
+ ),
92
+ apple: (
93
+ <>
94
+ <path d="M15.5 3c.2 1.4-.9 3-2.3 3.1-.2-1.4.9-3 2.3-3.1z" />
95
+ <path d="M18.5 16.5c-.6 1.6-1.9 3.5-3.2 3.5-1 0-1.4-.6-2.6-.6s-1.6.6-2.6.6c-1.4 0-3.2-2.6-3.7-4.6-.7-2.8 0-6.4 2.6-7 1.2-.3 2.3.6 3 .6.7 0 2-.8 3.3-.6.9.1 2.2.5 3 1.8-2.5 1.5-2.3 5 .2 6.3z" />
96
+ </>
97
+ ),
98
+ google: (
99
+ <>
100
+ <path d="M20.5 12A8.5 8.5 0 1 1 17.9 5.9" />
101
+ <path d="M20.5 12H12" />
102
+ </>
103
+ ),
104
+ email: (
105
+ <>
106
+ <rect x="3" y="5" width="18" height="14" rx="2" />
107
+ <path d="M3.5 6.5L12 13l8.5-6.5" />
108
+ </>
109
+ ),
110
+ phone: (
111
+ <path d="M6 3h3l1.5 4-2 1.5a12 12 0 0 0 5 5L15 11.5 19 13v3.5a1.5 1.5 0 0 1-1.7 1.5A15.5 15.5 0 0 1 4.5 5.2 1.5 1.5 0 0 1 6 3z" />
112
+ ),
113
+ globe: (
114
+ <>
115
+ <circle cx="12" cy="12" r="9" />
116
+ <path d="M3 12h18" />
117
+ <path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z" />
118
+ </>
119
+ )
120
+ };
121
+
122
+ export const PLATFORM_LABELS: Record<string, string> = {
123
+ twitter: 'Twitter / X',
124
+ globe: 'Website',
125
+ email: 'Email',
126
+ phone: 'Phone'
127
+ };
128
+
129
+ export const platformLabel = (platform: string) =>
130
+ PLATFORM_LABELS[platform] ||
131
+ (platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Link');
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import { DesignerPlaceholder } from './product-block-utils';
8
+ import { readResponsiveArray } from './responsive-array';
9
+ import { IconGlyph } from './icon-source';
10
+ import { SOCIAL_GLYPHS, platformLabel } from './social-glyphs';
11
+
12
+ /**
13
+ * What the editor's social icon panel writes per entry
14
+ * (pz-dynamic-widget `src/components/theme-editor/social-icons-editor.tsx`,
15
+ * `SocialIconItem`). There is no label and no caption here: the panel offers
16
+ * a platform, a link, a size and a colour, and nothing else.
17
+ */
18
+ interface SocialIconItem {
19
+ icon?: string;
20
+ link?: string;
21
+ size?: number | string;
22
+ color?: string;
23
+ }
24
+
25
+ /** The panel writes a number, but published JSON has carried strings too. */
26
+ const clampSize = (value: unknown, fallback: number) => {
27
+ const numeric = Math.round(Number(value));
28
+ if (!Number.isFinite(numeric)) return fallback;
29
+ return Math.min(64, Math.max(12, numeric));
30
+ };
31
+
32
+ /**
33
+ * Off-site, and therefore worth leaving the storefront for in a new tab.
34
+ * The descriptor advertises no "open in new tab" property, so the href itself
35
+ * has to answer it: a brand's own `/contact` stays in place, `https://…`
36
+ * does not.
37
+ */
38
+ const isExternalHref = (href: string) => /^(https?:)?\/\//i.test(href);
39
+
40
+ /**
41
+ * Functional chrome block: a row of social platform marks, each one a link.
42
+ * Entries are authored through the `icons` property (platform, url, size,
43
+ * colour); this block has no children, no captions and no layout properties —
44
+ * it is `social-links` reduced to the icons.
45
+ *
46
+ * NO BOX OF ITS OWN, deliberately. The entries are returned as siblings so
47
+ * they are the direct children of the `[data-block-id]` wrapper the generated
48
+ * stylesheet writes onto. The descriptor advertises `gap` as a block-level
49
+ * style, and a `gap` (or a `display: flex`, or a `grid-template-columns`) set
50
+ * on the wrapper only reaches the icons if nothing of ours sits in between —
51
+ * a list element here would collect the whole row into one flex item.
52
+ *
53
+ * Properties: icons.
54
+ * Parts: link, icon.
55
+ */
56
+ const SocialIconsListBlock = ({
57
+ block,
58
+ currentBreakpoint = 'desktop',
59
+ isDesigner = false
60
+ }: BlockRendererProps) => {
61
+ // `icons`, not `items` — the property key differs from the sibling block.
62
+ const items = readResponsiveArray<SocialIconItem>(
63
+ block.properties?.icons,
64
+ currentBreakpoint
65
+ );
66
+
67
+ const entries = items
68
+ .filter((item) => item && (item.icon || item.link))
69
+ .map((item, index) => {
70
+ const platform = String(item.icon || 'globe').toLowerCase();
71
+ const href = String(item.link || '').trim();
72
+
73
+ return {
74
+ key: `${platform}-${index}`,
75
+ platform,
76
+ href,
77
+ label: platformLabel(platform),
78
+ // 24 is what the panel seeds a new entry with, so an entry saved
79
+ // before the size field existed matches the ones saved after it.
80
+ size: clampSize(item.size, 24),
81
+ color: String(item.color || '')
82
+ };
83
+ });
84
+
85
+ if (entries.length === 0) {
86
+ return isDesigner ? (
87
+ <DesignerPlaceholder
88
+ label="Social icons (no entries)"
89
+ attribute="data-social-icons-list"
90
+ />
91
+ ) : null;
92
+ }
93
+
94
+ return (
95
+ <>
96
+ {entries.map((entry) => {
97
+ // An unknown platform still draws something recognisable rather than
98
+ // an empty box; the picker and this table are separate lists.
99
+ const glyph = SOCIAL_GLYPHS[entry.platform] || SOCIAL_GLYPHS.globe;
100
+
101
+ const icon = (
102
+ <IconGlyph
103
+ glyph={glyph}
104
+ size={entry.size}
105
+ data-social-icon-glyph=""
106
+ {...partAttrs('icon')}
107
+ />
108
+ );
109
+
110
+ // Per-entry colour is the one authored value with no other channel:
111
+ // it differs per icon, so no block or part rule can express it. It
112
+ // lands on the link rather than on the glyph so that a part rule for
113
+ // `icon` still has somewhere to win from.
114
+ const colorStyle = entry.color ? { color: entry.color } : undefined;
115
+
116
+ if (!entry.href) {
117
+ return (
118
+ <span
119
+ key={entry.key}
120
+ // A mark with no link is a logo, not a control — announced as an
121
+ // image so it is not silent, but not focusable.
122
+ role="img"
123
+ aria-label={entry.label}
124
+ data-social-icon={entry.platform}
125
+ style={colorStyle}
126
+ {...partAttrs('link')}
127
+ >
128
+ {icon}
129
+ </span>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <NextLink
135
+ key={entry.key}
136
+ // Navigating inside the designer iframe would drop the author out
137
+ // of the page they are editing, so the icons are inert there.
138
+ href={isDesigner ? '#' : entry.href}
139
+ onClick={(event) => {
140
+ if (isDesigner) event.preventDefault();
141
+ }}
142
+ aria-label={entry.label}
143
+ title={entry.label}
144
+ data-social-icon={entry.platform}
145
+ style={colorStyle}
146
+ {...partAttrs('link')}
147
+ {...(!isDesigner && isExternalHref(entry.href)
148
+ ? { target: '_blank', rel: 'noopener noreferrer' }
149
+ : {})}
150
+ >
151
+ {icon}
152
+ </NextLink>
153
+ );
154
+ })}
155
+ </>
156
+ );
157
+ };
158
+
159
+ export default SocialIconsListBlock;
@@ -6,6 +6,7 @@ import { getResponsiveValue, partAttrs } from '../utils';
6
6
  import { BlockRendererProps } from './block-renderer-registry';
7
7
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
8
8
  import { hasItemTemplate, ItemTemplateList } from './item-template';
9
+ import { SOCIAL_GLYPHS, platformLabel } from './social-glyphs';
9
10
 
10
11
  interface SocialLinkItem {
11
12
  icon?: string;
@@ -16,131 +17,6 @@ interface SocialLinkItem {
16
17
  label?: string;
17
18
  }
18
19
 
19
- /**
20
- * Stroke glyphs on the shared 24x24 grid the other chrome blocks draw on, so a
21
- * single `stroke: currentColor` inherits the header colour. The keys are the
22
- * platform values the editor's social icon picker stores.
23
- */
24
- const SOCIAL_GLYPHS: Record<string, React.ReactNode> = {
25
- facebook: (
26
- <path d="M14 8.5V7a1.5 1.5 0 0 1 1.5-1.5H17V3h-2A4 4 0 0 0 11 7v1.5H9V11h2v10h3V11h2.2l.4-2.5H14z" />
27
- ),
28
- instagram: (
29
- <>
30
- <rect x="3" y="3" width="18" height="18" rx="5" />
31
- <circle cx="12" cy="12" r="4" />
32
- <circle cx="17.4" cy="6.6" r="0.6" />
33
- </>
34
- ),
35
- twitter: <path d="M4 4l16 16M20 4L4 20" />,
36
- youtube: (
37
- <>
38
- <rect x="2.5" y="6" width="19" height="12" rx="4" />
39
- <path d="M10 9.5l5 2.5-5 2.5z" />
40
- </>
41
- ),
42
- linkedin: (
43
- <>
44
- <path d="M6.5 9.5V20" />
45
- <circle cx="6.5" cy="5" r="1.1" />
46
- <path d="M11 20v-6.5a3.5 3.5 0 0 1 7 0V20" />
47
- </>
48
- ),
49
- tiktok: (
50
- <>
51
- <path d="M14 4v11a4 4 0 1 1-4-4" />
52
- <path d="M14 4c.5 2.6 2.2 4.2 5 4.4" />
53
- </>
54
- ),
55
- pinterest: (
56
- <>
57
- <circle cx="12" cy="12" r="9" />
58
- <path d="M9.5 21l2.2-8.5" />
59
- <path d="M9 11a3 3 0 1 1 5 2.2" />
60
- </>
61
- ),
62
- whatsapp: (
63
- <>
64
- <path d="M3.5 20.5l1.4-4.2A8.2 8.2 0 1 1 8 19.4z" />
65
- <path d="M9 9.5c0 3 2.5 5.5 5.5 5.5l1-1.6-2-1-1 .9a5 5 0 0 1-1.8-1.8l.9-1-1-2z" />
66
- </>
67
- ),
68
- telegram: <path d="M21 4.5L2.8 11.3l5 1.8L18 7l-7.5 7.4.3 5 2.7-3.4 4 3z" />,
69
- discord: (
70
- <>
71
- <path d="M9 18c-2 0-4-1-4.5-2.5C4 12 5 8.5 6.5 7A11 11 0 0 1 12 5.8 11 11 0 0 1 17.5 7C19 8.5 20 12 19.5 15.5 19 17 17 18 15 18" />
72
- <circle cx="9.5" cy="13" r="1.1" />
73
- <circle cx="14.5" cy="13" r="1.1" />
74
- </>
75
- ),
76
- snapchat: (
77
- <path d="M12 4c2.8 0 4.3 2 4.3 4.6v2c.9.5 1.8.4 2.4.9-.8 1.4-1.9 1.5-1.9 2.3 0 1 1.7 2 2.7 2.2-.9 1.2-2.8 1.2-3.7 1.9-.9.7-1.5 1.1-3.8 1.1s-2.9-.4-3.8-1.1c-.9-.7-2.8-.7-3.7-1.9 1-.2 2.7-1.2 2.7-2.2 0-.8-1.1-.9-1.9-2.3.6-.5 1.5-.4 2.4-.9v-2C7.7 6 9.2 4 12 4z" />
78
- ),
79
- reddit: (
80
- <>
81
- <circle cx="12" cy="13" r="8" />
82
- <circle cx="9.5" cy="12.5" r="1" />
83
- <circle cx="14.5" cy="12.5" r="1" />
84
- <path d="M9 16c1.8 1.2 4.2 1.2 6 0" />
85
- <path d="M15 5.6l.7 3.4" />
86
- <circle cx="16.4" cy="4.6" r="1.1" />
87
- </>
88
- ),
89
- twitch: (
90
- <>
91
- <path d="M4.5 4h15v9.5L15 18h-3l-3 3H7.5v-3h-3z" />
92
- <path d="M11 8v4M15 8v4" />
93
- </>
94
- ),
95
- spotify: (
96
- <>
97
- <circle cx="12" cy="12" r="9" />
98
- <path d="M7.5 9.5c3-1 6.5-.6 9 1" />
99
- <path d="M8.2 13c2.4-.8 5.2-.5 7.3.9" />
100
- <path d="M9 16.2c1.8-.6 3.9-.4 5.5.7" />
101
- </>
102
- ),
103
- apple: (
104
- <>
105
- <path d="M15.5 3c.2 1.4-.9 3-2.3 3.1-.2-1.4.9-3 2.3-3.1z" />
106
- <path d="M18.5 16.5c-.6 1.6-1.9 3.5-3.2 3.5-1 0-1.4-.6-2.6-.6s-1.6.6-2.6.6c-1.4 0-3.2-2.6-3.7-4.6-.7-2.8 0-6.4 2.6-7 1.2-.3 2.3.6 3 .6.7 0 2-.8 3.3-.6.9.1 2.2.5 3 1.8-2.5 1.5-2.3 5 .2 6.3z" />
107
- </>
108
- ),
109
- google: (
110
- <>
111
- <path d="M20.5 12A8.5 8.5 0 1 1 17.9 5.9" />
112
- <path d="M20.5 12H12" />
113
- </>
114
- ),
115
- email: (
116
- <>
117
- <rect x="3" y="5" width="18" height="14" rx="2" />
118
- <path d="M3.5 6.5L12 13l8.5-6.5" />
119
- </>
120
- ),
121
- phone: (
122
- <path d="M6 3h3l1.5 4-2 1.5a12 12 0 0 0 5 5L15 11.5 19 13v3.5a1.5 1.5 0 0 1-1.7 1.5A15.5 15.5 0 0 1 4.5 5.2 1.5 1.5 0 0 1 6 3z" />
123
- ),
124
- globe: (
125
- <>
126
- <circle cx="12" cy="12" r="9" />
127
- <path d="M3 12h18" />
128
- <path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z" />
129
- </>
130
- )
131
- };
132
-
133
- const PLATFORM_LABELS: Record<string, string> = {
134
- twitter: 'Twitter / X',
135
- globe: 'Website',
136
- email: 'Email',
137
- phone: 'Phone'
138
- };
139
-
140
- const platformLabel = (platform: string) =>
141
- PLATFORM_LABELS[platform] ||
142
- (platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Link');
143
-
144
20
  /**
145
21
  * `platform=Label` pairs, one per `;` or newline. The built-in names are
146
22
  * English and singular ("Twitter / X"); a brand running in another language
@@ -0,0 +1,152 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import { DesignerPlaceholder } from './product-block-utils';
7
+ import { readResponsiveArray } from './responsive-array';
8
+ import { IconGlyph, resolveIconSource } from './icon-source';
9
+ import { useThemeSettingsContext } from '../theme-settings-context';
10
+
11
+ /**
12
+ * What the editor's value proposition panel writes per entry
13
+ * (pz-dynamic-widget `src/components/theme-editor/value-props-editor.tsx`,
14
+ * `ValuePropItem`). The icon is a catalogue KEY — that panel's picker calls
15
+ * `onSelect(iconKey)` and stores the key even when the brand uploaded its own
16
+ * mark, which is why the key is resolved rather than used as a src.
17
+ */
18
+ interface ValuePropItem {
19
+ icon?: string;
20
+ title?: string;
21
+ description?: string;
22
+ }
23
+
24
+ /**
25
+ * Functional chrome block: the "free shipping / secure payment / easy returns"
26
+ * strip. Rows are authored through the `items` property, which the descriptor
27
+ * SEEDS WITH THREE ENTRIES — this block draws content the moment it is
28
+ * inserted, so a list read that silently yielded nothing would ship as a
29
+ * visibly empty strip rather than as a placeholder anyone notices.
30
+ *
31
+ * NO BOX OF ITS OWN, deliberately: the rows are returned as siblings so they
32
+ * are the direct children of the `[data-block-id]` wrapper the generated
33
+ * stylesheet writes onto. That is what lets the three-across desktop grid and
34
+ * the stacked mobile list (the shape the editor previews) come from a
35
+ * `display`/`gap`/`grid-template-columns` set on the block itself. A wrapper
36
+ * of ours would take those rules and leave the rows unarranged inside it.
37
+ *
38
+ * Properties: items.
39
+ * Parts: item, icon, text, title, description.
40
+ */
41
+ const ValuePropsListBlock = ({
42
+ block,
43
+ currentBreakpoint = 'desktop',
44
+ isDesigner = false
45
+ }: BlockRendererProps) => {
46
+ const themeSettings = useThemeSettingsContext();
47
+
48
+ const items = readResponsiveArray<ValuePropItem>(
49
+ block.properties?.items,
50
+ currentBreakpoint
51
+ );
52
+
53
+ const rows = items
54
+ .filter((item) => item && (item.icon || item.title || item.description))
55
+ .map((item, index) => ({
56
+ key: `${item.title || item.icon || 'row'}-${index}`,
57
+ icon: String(item.icon || '').trim(),
58
+ title: String(item.title || '').trim(),
59
+ description: String(item.description || '').trim()
60
+ }));
61
+
62
+ if (rows.length === 0) {
63
+ return isDesigner ? (
64
+ <DesignerPlaceholder
65
+ label="Value propositions (no entries)"
66
+ attribute="data-value-props-list"
67
+ />
68
+ ) : null;
69
+ }
70
+
71
+ return (
72
+ <>
73
+ {rows.map((row) => {
74
+ const source = resolveIconSource(row.icon, themeSettings);
75
+
76
+ let icon: React.ReactNode = null;
77
+
78
+ if (source.kind === 'glyph') {
79
+ icon = (
80
+ <IconGlyph
81
+ glyph={source.glyph}
82
+ data-value-prop-icon=""
83
+ {...partAttrs('icon')}
84
+ />
85
+ );
86
+ } else if (source.kind === 'svg') {
87
+ icon = (
88
+ <span
89
+ data-value-prop-icon=""
90
+ {...partAttrs('icon')}
91
+ // Brand-uploaded theme-settings markup, injected the way
92
+ // `icon-block` and `text-block` already inject stored markup.
93
+ // Sanitization is a package-wide decision, not this block's.
94
+ dangerouslySetInnerHTML={{ __html: source.markup }}
95
+ />
96
+ );
97
+ } else if (source.kind === 'url') {
98
+ icon = (
99
+ // Plain img: an uploaded mark is served from the brand's own CDN,
100
+ // which the next.config image allowlist does not necessarily
101
+ // cover. Empty alt because the title beside it carries the
102
+ // meaning — an icon named twice is worse than an unnamed one.
103
+ // eslint-disable-next-line @next/next/no-img-element
104
+ <img
105
+ src={source.url}
106
+ alt=""
107
+ loading="lazy"
108
+ width={24}
109
+ height={24}
110
+ data-value-prop-icon=""
111
+ {...partAttrs('icon')}
112
+ />
113
+ );
114
+ }
115
+ // `kind: 'none'` draws no icon element at all. The panel's add form
116
+ // starts on an icon name that is in no catalogue, so an entry saved
117
+ // without opening the picker resolves to nothing — the row still has
118
+ // to carry its title.
119
+
120
+ const hasText = Boolean(row.title || row.description);
121
+
122
+ return (
123
+ <div key={row.key} data-value-prop="" {...partAttrs('item')}>
124
+ {icon}
125
+ {hasText && (
126
+ // The text sits in its own box so the row can be laid out as
127
+ // "icon beside a stacked title and description" without the
128
+ // three pieces becoming siblings of each other.
129
+ <div data-value-prop-text="" {...partAttrs('text')}>
130
+ {row.title && (
131
+ <span data-value-prop-title="" {...partAttrs('title')}>
132
+ {row.title}
133
+ </span>
134
+ )}
135
+ {row.description && (
136
+ <span
137
+ data-value-prop-description=""
138
+ {...partAttrs('description')}
139
+ >
140
+ {row.description}
141
+ </span>
142
+ )}
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ })}
148
+ </>
149
+ );
150
+ };
151
+
152
+ export default ValuePropsListBlock;