@akinon/pz-theme 2.0.96-rc.0 → 2.0.97-beta.0
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/CHANGELOG.md +15 -33
- package/package.json +4 -4
- package/src/__tests__/registry-parity.test.ts +11 -11
- package/src/blocks/__tests__/carousel-item-block.test.tsx +346 -0
- package/src/blocks/__tests__/container-block.test.tsx +149 -0
- package/src/blocks/__tests__/hover-showcase-item-block.test.tsx +315 -0
- package/src/blocks/__tests__/icon-button-block.test.tsx +231 -0
- package/src/blocks/__tests__/icon-group-block.test.tsx +186 -0
- package/src/blocks/__tests__/icon-source.test.tsx +226 -0
- package/src/blocks/__tests__/responsive-array.test.ts +77 -0
- package/src/blocks/__tests__/social-glyphs.test.tsx +108 -0
- package/src/blocks/__tests__/social-icons-list-block.test.tsx +243 -0
- package/src/blocks/__tests__/value-props-list-block.test.tsx +231 -0
- package/src/blocks/block-renderer-registry.tsx +21 -0
- package/src/blocks/carousel-item-block.tsx +168 -0
- package/src/blocks/container-block.tsx +80 -0
- package/src/blocks/hover-showcase-item-block.tsx +232 -0
- package/src/blocks/icon-block.tsx +82 -121
- package/src/blocks/icon-button-block.tsx +180 -0
- package/src/blocks/icon-group-block.tsx +119 -0
- package/src/blocks/icon-group-colored-block.tsx +47 -0
- package/src/blocks/icon-source.tsx +311 -0
- package/src/blocks/responsive-array.ts +54 -0
- package/src/blocks/social-glyphs.tsx +131 -0
- package/src/blocks/social-icons-list-block.tsx +159 -0
- package/src/blocks/social-links-block.tsx +1 -125
- package/src/blocks/value-props-list-block.tsx +152 -0
|
@@ -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;
|