@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.
- package/CHANGELOG.md +7 -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,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Image } from '@akinon/next/components/image';
|
|
5
|
+
import { getResponsiveValue } from '../utils';
|
|
6
|
+
import { escapeCssAttributeValue } from '../utils/css-safety';
|
|
7
|
+
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
8
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
10
|
+
import type { IconSource } from './icon-source';
|
|
11
|
+
import { IconGlyph, resolveIconSource } from './icon-source';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A control that is nothing but an icon: the descriptor
|
|
15
|
+
* (pz-dynamic-widget `block-registry.ts`, `BlockType.ICON_BUTTON`) declares
|
|
16
|
+
* exactly two properties — `icon` and `iconSize` — plus the shared ones every
|
|
17
|
+
* block gets. Deliberately narrower than the plain `icon` block, which also
|
|
18
|
+
* carries `alt` and position controls; neither exists here and neither is
|
|
19
|
+
* invented.
|
|
20
|
+
*
|
|
21
|
+
* What makes it read as a button is the box: `color`, `background-color` and
|
|
22
|
+
* the four margins are real CSS properties, so the generated rule paints them
|
|
23
|
+
* on the `[data-block-id]` wrapper and this file never touches them. `color`
|
|
24
|
+
* arriving there is also how the glyph is tinted, since every glyph is drawn
|
|
25
|
+
* with `currentColor`.
|
|
26
|
+
*
|
|
27
|
+
* Clicking is not wired here either. `WithDesignerFeatures` reads `actionType`
|
|
28
|
+
* / `actionTarget` centrally and turns the wrapper into the trigger; this
|
|
29
|
+
* renderer draws no anchor and no button, so the type stays out of
|
|
30
|
+
* `NATIVELY_INTERACTIVE_BLOCK_TYPES`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The name a freshly dropped block carries.
|
|
35
|
+
*
|
|
36
|
+
* The editor seeds `block.label` from the descriptor's `name`
|
|
37
|
+
* (`block-crud.reducers.ts`: `label: blockConfig.name`), so "Icon Button" is
|
|
38
|
+
* the ABSENCE of an authored name rather than one — announcing it to a screen
|
|
39
|
+
* reader would be pure noise.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_BLOCK_LABEL = 'icon button';
|
|
42
|
+
|
|
43
|
+
const DEFAULT_ICON_SIZE = 20;
|
|
44
|
+
|
|
45
|
+
/** `favourite-empty` -> `Favourite empty`. */
|
|
46
|
+
const humanizeIconKey = (key: string): string => {
|
|
47
|
+
const words = key.replace(/[-_]+/g, ' ').trim();
|
|
48
|
+
if (!words) return '';
|
|
49
|
+
|
|
50
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where the accessible name comes from, given a descriptor that offers no
|
|
55
|
+
* `alt` and no `ariaLabel`.
|
|
56
|
+
*
|
|
57
|
+
* The only authorable text on this block is its layer-tree name — the editor
|
|
58
|
+
* persists a rename into `block.label` (RENAME_BLOCK), and the wrapper's
|
|
59
|
+
* `ariaProps` carry `aria-haspopup`/`aria-expanded` but never a name. So when
|
|
60
|
+
* an action turns that wrapper into `role="button"`, the name it computes
|
|
61
|
+
* comes from this subtree and nowhere else.
|
|
62
|
+
*
|
|
63
|
+
* Falling back to the picked catalogue key ("Basket") beats falling back to
|
|
64
|
+
* nothing: it is what the designer chose, and it is what the icon depicts.
|
|
65
|
+
*/
|
|
66
|
+
export const readIconButtonName = (
|
|
67
|
+
label: string | undefined,
|
|
68
|
+
source: IconSource
|
|
69
|
+
): string => {
|
|
70
|
+
const authored = String(label || '').trim();
|
|
71
|
+
if (authored && authored.toLowerCase() !== DEFAULT_BLOCK_LABEL) {
|
|
72
|
+
return authored;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return source.kind === 'glyph' ? humanizeIconKey(source.key) : '';
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const readSize = (value: unknown): number => {
|
|
79
|
+
const numeric = Number(
|
|
80
|
+
String(value ?? '')
|
|
81
|
+
.replace(/px$/i, '')
|
|
82
|
+
.trim()
|
|
83
|
+
);
|
|
84
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_ICON_SIZE;
|
|
85
|
+
|
|
86
|
+
return Math.min(256, numeric);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const IconButtonBlock = ({
|
|
90
|
+
block,
|
|
91
|
+
currentBreakpoint = 'desktop',
|
|
92
|
+
isDesigner = false
|
|
93
|
+
}: BlockRendererProps) => {
|
|
94
|
+
const themeSettings = useThemeSettingsContext();
|
|
95
|
+
|
|
96
|
+
// Both are bucketed per breakpoint on a dropped block — the descriptor's
|
|
97
|
+
// `getDefaultValue` seeds `{ desktop: ... }` for each — which is the one
|
|
98
|
+
// shape a flat read would get wrong.
|
|
99
|
+
const iconValue = getResponsiveValue(
|
|
100
|
+
block.properties?.icon,
|
|
101
|
+
currentBreakpoint,
|
|
102
|
+
''
|
|
103
|
+
);
|
|
104
|
+
const iconSize = readSize(
|
|
105
|
+
getResponsiveValue(
|
|
106
|
+
block.properties?.iconSize,
|
|
107
|
+
currentBreakpoint,
|
|
108
|
+
DEFAULT_ICON_SIZE
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const source = resolveIconSource(iconValue, themeSettings);
|
|
113
|
+
const name = readIconButtonName(block.label, source);
|
|
114
|
+
|
|
115
|
+
// An icon with no name is decoration, and decoration must stay out of the
|
|
116
|
+
// accessibility tree: announcing an unnamed graphic gives a screen reader
|
|
117
|
+
// user a stop with nothing to read at it.
|
|
118
|
+
const labelAttrs = name
|
|
119
|
+
? { role: 'img', 'aria-label': name, 'aria-hidden': undefined }
|
|
120
|
+
: { 'aria-hidden': true as const };
|
|
121
|
+
|
|
122
|
+
if (source.kind === 'none') {
|
|
123
|
+
return isDesigner ? (
|
|
124
|
+
<DesignerPlaceholder
|
|
125
|
+
label="Icon button (no icon)"
|
|
126
|
+
attribute="data-icon-button"
|
|
127
|
+
/>
|
|
128
|
+
) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (source.kind === 'glyph') {
|
|
132
|
+
return (
|
|
133
|
+
<IconGlyph
|
|
134
|
+
data-icon-button=""
|
|
135
|
+
glyph={source.glyph}
|
|
136
|
+
size={iconSize}
|
|
137
|
+
{...labelAttrs}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (source.kind === 'svg') {
|
|
143
|
+
// The uploaded markup carries its own width/height attributes, so the
|
|
144
|
+
// authored size has to reach the injected `<svg>` rather than the span
|
|
145
|
+
// around it. A scoped rule rather than `icon-block`'s Tailwind arbitrary
|
|
146
|
+
// variants: those only exist if the consuming brand's Tailwind build
|
|
147
|
+
// scans this package, and a size control that silently does nothing on
|
|
148
|
+
// some brands is worse than one extra rule on all of them.
|
|
149
|
+
const scopeId = block.styleSourceId || block.id;
|
|
150
|
+
const markupCss =
|
|
151
|
+
`[data-icon-button-id="${escapeCssAttributeValue(scopeId)}"] svg` +
|
|
152
|
+
`{width:${iconSize}px;height:${iconSize}px;}`;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<span
|
|
156
|
+
data-icon-button=""
|
|
157
|
+
data-icon-button-id={scopeId}
|
|
158
|
+
{...labelAttrs}
|
|
159
|
+
style={{ display: 'inline-flex' }}
|
|
160
|
+
>
|
|
161
|
+
<style>{markupCss}</style>
|
|
162
|
+
<span dangerouslySetInnerHTML={{ __html: source.markup }} />
|
|
163
|
+
</span>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Image
|
|
169
|
+
data-icon-button=""
|
|
170
|
+
src={source.url}
|
|
171
|
+
width={iconSize}
|
|
172
|
+
height={iconSize}
|
|
173
|
+
// An empty alt is the correct announcement for an unnamed icon; the
|
|
174
|
+
// component defaults it to '' anyway, and this makes that deliberate.
|
|
175
|
+
alt={name}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export default IconButtonBlock;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { getResponsiveValue } from '../utils';
|
|
5
|
+
import { escapeCssAttributeValue } from '../utils/css-safety';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
8
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A set of icons sized together.
|
|
12
|
+
*
|
|
13
|
+
* The descriptor (pz-dynamic-widget `block-registry.ts`,
|
|
14
|
+
* `BlockType.ICON_GROUP`) declares `properties: []` and a single `size` style,
|
|
15
|
+
* so everything this block owns is the size of the icons underneath it — the
|
|
16
|
+
* children are ordinary blocks, rendered through the package's own
|
|
17
|
+
* `renderComposedChildren` so they stay movable, hideable and renamable on the
|
|
18
|
+
* canvas.
|
|
19
|
+
*
|
|
20
|
+
* `size` is the one style key this file may read: it is not a CSS property any
|
|
21
|
+
* element understands, so the generator emits `size: 18` onto the wrapper and
|
|
22
|
+
* the browser drops it. Every other style the merchant sets — display, gap,
|
|
23
|
+
* padding, background — already lands on that wrapper and is deliberately
|
|
24
|
+
* untouched here.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The descriptor's own default, so an unset control still sizes the set. */
|
|
28
|
+
const DEFAULT_ICON_SIZE = 18;
|
|
29
|
+
|
|
30
|
+
export interface IconGroupBlockProps extends BlockRendererProps {
|
|
31
|
+
/**
|
|
32
|
+
* A resolved colour to tint the set with. Only `icon-group-colored` passes
|
|
33
|
+
* one; see that file for why it must already be a real colour by the time it
|
|
34
|
+
* arrives.
|
|
35
|
+
*/
|
|
36
|
+
tint?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const readSize = (value: unknown): number => {
|
|
40
|
+
const numeric = Number(
|
|
41
|
+
String(value ?? '')
|
|
42
|
+
.replace(/px$/i, '')
|
|
43
|
+
.trim()
|
|
44
|
+
);
|
|
45
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_ICON_SIZE;
|
|
46
|
+
|
|
47
|
+
return Math.min(256, numeric);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const IconGroupBlock = ({
|
|
51
|
+
block,
|
|
52
|
+
placeholderId,
|
|
53
|
+
sectionId,
|
|
54
|
+
isDesigner,
|
|
55
|
+
selectedBlockId,
|
|
56
|
+
currentBreakpoint = 'desktop',
|
|
57
|
+
tint
|
|
58
|
+
}: IconGroupBlockProps) => {
|
|
59
|
+
const children = renderComposedChildren({
|
|
60
|
+
blocks: block.blocks,
|
|
61
|
+
placeholderId,
|
|
62
|
+
sectionId,
|
|
63
|
+
isDesigner,
|
|
64
|
+
selectedBlockId,
|
|
65
|
+
currentBreakpoint
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (children.length === 0) {
|
|
69
|
+
return isDesigner ? (
|
|
70
|
+
<DesignerPlaceholder
|
|
71
|
+
label="Icon group (no icons)"
|
|
72
|
+
attribute="data-icon-group"
|
|
73
|
+
/>
|
|
74
|
+
) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const size = readSize(
|
|
78
|
+
getResponsiveValue(block.styles?.size, currentBreakpoint, DEFAULT_ICON_SIZE)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// A stylesheet rather than inline styles because the elements being sized
|
|
82
|
+
// belong to the CHILD blocks: this block cannot reach into their markup, and
|
|
83
|
+
// a CSS rule is the only thing that crosses that boundary. Scoped to this
|
|
84
|
+
// block's own id so it sizes its own set and nothing else on the page, and
|
|
85
|
+
// written as a descendant rule so it outranks the width/height ATTRIBUTES an
|
|
86
|
+
// icon carries (a presentation attribute loses to any declaration).
|
|
87
|
+
//
|
|
88
|
+
// Safe to write as a bare `svg`/`img` descendant: the only other icons that
|
|
89
|
+
// could appear under this block are the designer's own action-toolbar
|
|
90
|
+
// glyphs, and that toolbar is portaled to <body> (action-toolbar.tsx), so it
|
|
91
|
+
// is never a DOM descendant of the group.
|
|
92
|
+
const scopeId = block.styleSourceId || block.id;
|
|
93
|
+
const scope = `[data-icon-group-id="${escapeCssAttributeValue(scopeId)}"]`;
|
|
94
|
+
const sizeCss = `${scope} svg,${scope} img{width:${size}px;height:${size}px;}`;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<span
|
|
98
|
+
data-icon-group=""
|
|
99
|
+
data-icon-group-id={scopeId}
|
|
100
|
+
style={{
|
|
101
|
+
// Transparent to layout, so the merchant's `display: flex` and `gap`
|
|
102
|
+
// on the wrapper reach the icons instead of stopping at one flex item
|
|
103
|
+
// — the same reason `group-block` renders `className="contents"`.
|
|
104
|
+
// Inline rather than the Tailwind class because a brand whose Tailwind
|
|
105
|
+
// build does not scan this package would otherwise get a plain inline
|
|
106
|
+
// box here and lose the row layout entirely.
|
|
107
|
+
display: 'contents',
|
|
108
|
+
// Inherits into every child that draws with `currentColor`. Absent on
|
|
109
|
+
// the plain group, which is what keeps the twins one implementation.
|
|
110
|
+
...(tint ? { color: tint } : null)
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<style>{sizeCss}</style>
|
|
114
|
+
{children}
|
|
115
|
+
</span>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default IconGroupBlock;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { getResponsiveValue, resolveThemeCssVariables } from '../utils';
|
|
5
|
+
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import IconGroupBlock from './icon-group-block';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The icon group with the colour control on.
|
|
11
|
+
*
|
|
12
|
+
* Its descriptor is the plain group's descriptor plus one `color` style, so
|
|
13
|
+
* this is the plain renderer plus one resolved tint — not a second copy of it.
|
|
14
|
+
* Twins that are hand-copied drift: a fix to child rendering, to the empty
|
|
15
|
+
* state or to the size rule would have to be remembered twice, and the second
|
|
16
|
+
* one is the one nobody remembers.
|
|
17
|
+
*
|
|
18
|
+
* Why the colour is read here at all, when a real CSS property normally
|
|
19
|
+
* belongs to the generated rule on the wrapper: the swatch stores theme
|
|
20
|
+
* colours as the literal string `var(--theme-primary)`, and the storefront is
|
|
21
|
+
* not obliged to define that custom property anywhere — the theme's colours
|
|
22
|
+
* live in the theme SETTINGS object, which only `resolveThemeCssVariables`
|
|
23
|
+
* can look them up in. So the generated `color: var(--theme-primary)` can
|
|
24
|
+
* resolve to nothing at all, and an icon drawn with `currentColor` inherits
|
|
25
|
+
* whatever the page happened to have.
|
|
26
|
+
*
|
|
27
|
+
* The tint is therefore applied ONLY when resolution actually changed the
|
|
28
|
+
* string, i.e. when a token was mapped to a real colour. A plain hex is left
|
|
29
|
+
* entirely to the stylesheet, which keeps the merchant's per-breakpoint
|
|
30
|
+
* `color` — and the media queries the same sheet emits for it — intact.
|
|
31
|
+
*/
|
|
32
|
+
const IconGroupColoredBlock = (props: BlockRendererProps) => {
|
|
33
|
+
const themeSettings = useThemeSettingsContext();
|
|
34
|
+
const { block, currentBreakpoint = 'desktop' } = props;
|
|
35
|
+
|
|
36
|
+
const authored = String(
|
|
37
|
+
getResponsiveValue(block.styles?.color, currentBreakpoint, '') ?? ''
|
|
38
|
+
).trim();
|
|
39
|
+
const resolved = authored
|
|
40
|
+
? resolveThemeCssVariables(authored, themeSettings)
|
|
41
|
+
: '';
|
|
42
|
+
const tint = resolved && resolved !== authored ? resolved : undefined;
|
|
43
|
+
|
|
44
|
+
return <IconGroupBlock {...props} tint={tint} />;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default IconGroupColoredBlock;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keys the editor's icon picker offers out of the box.
|
|
5
|
+
*
|
|
6
|
+
* Source of truth is the editor's `DEFAULT_ICON_CATEGORIES`
|
|
7
|
+
* (pz-dynamic-widget `src/pages/settings/constants.ts`), grouped there as
|
|
8
|
+
* navigation / actions / interface / visibility / miscellaneous. The picker
|
|
9
|
+
* stores the KEY when the brand has not uploaded its own SVG for it
|
|
10
|
+
* (`icon-input.tsx` saves the uploaded markup when there is any, and the bare
|
|
11
|
+
* key when there is not), so a storefront whose theme settings carry no icons
|
|
12
|
+
* was receiving values like `basket` with nothing to draw them with.
|
|
13
|
+
*
|
|
14
|
+
* Drawn to one recipe on purpose — 24x24, `stroke="currentColor"`,
|
|
15
|
+
* `fill="none"`, stroke width 1.5, round caps and joins — because these sit
|
|
16
|
+
* next to each other in a header row, and a set that mixes weights reads as
|
|
17
|
+
* broken long before anyone can say which glyph is wrong.
|
|
18
|
+
*/
|
|
19
|
+
export const ICON_GLYPHS: Record<string, React.ReactNode> = {
|
|
20
|
+
// navigation
|
|
21
|
+
home: (
|
|
22
|
+
<path d="M3.5 10.8L12 4l8.5 6.8V19a1.5 1.5 0 0 1-1.5 1.5h-4.2v-6H9.2v6H5A1.5 1.5 0 0 1 3.5 19z" />
|
|
23
|
+
),
|
|
24
|
+
'back-arrow': <path d="M15 4.5L7.5 12l7.5 7.5" />,
|
|
25
|
+
'right-arrow': <path d="M9 4.5l7.5 7.5L9 19.5" />,
|
|
26
|
+
'down-arrow': <path d="M4.5 9l7.5 7.5L19.5 9" />,
|
|
27
|
+
'up-arrow': <path d="M4.5 15l7.5-7.5 7.5 7.5" />,
|
|
28
|
+
|
|
29
|
+
// actions
|
|
30
|
+
add: <path d="M12 4.5v15M4.5 12h15" />,
|
|
31
|
+
remove: <path d="M4.5 12h15" />,
|
|
32
|
+
close: <path d="M5.5 5.5l13 13M18.5 5.5l-13 13" />,
|
|
33
|
+
check: <path d="M4.5 12.5l5 5 10-11" />,
|
|
34
|
+
'circle-check': (
|
|
35
|
+
<>
|
|
36
|
+
<circle cx="12" cy="12" r="8.5" />
|
|
37
|
+
<path d="M8 12.2l2.6 2.6L16 9.4" />
|
|
38
|
+
</>
|
|
39
|
+
),
|
|
40
|
+
'filter-icon': <path d="M4 5.5h16l-6.2 7.2V19l-3.6-2v-4.3z" />,
|
|
41
|
+
'sort-icon': (
|
|
42
|
+
<>
|
|
43
|
+
<path d="M7 4.5v15M3.8 16.3L7 19.5l3.2-3.2" />
|
|
44
|
+
<path d="M17 19.5v-15M13.8 7.7L17 4.5l3.2 3.2" />
|
|
45
|
+
</>
|
|
46
|
+
),
|
|
47
|
+
// The picker control itself, not a bare chevron: a lone chevron here would
|
|
48
|
+
// be indistinguishable from `down-arrow` two rows up in the same grid.
|
|
49
|
+
'picker-icon': (
|
|
50
|
+
<>
|
|
51
|
+
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
|
52
|
+
<path d="M8.5 10.5l3.5 3.5 3.5-3.5" />
|
|
53
|
+
</>
|
|
54
|
+
),
|
|
55
|
+
spinner: <path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5" />,
|
|
56
|
+
|
|
57
|
+
// interface
|
|
58
|
+
search: (
|
|
59
|
+
<>
|
|
60
|
+
<circle cx="10.8" cy="10.8" r="6.3" />
|
|
61
|
+
<path d="M15.4 15.4l5 5" />
|
|
62
|
+
</>
|
|
63
|
+
),
|
|
64
|
+
basket: (
|
|
65
|
+
<>
|
|
66
|
+
<path d="M5 8h14l-1.1 12.5H6.1z" />
|
|
67
|
+
<path d="M9 8V6.4a3 3 0 0 1 6 0V8" />
|
|
68
|
+
</>
|
|
69
|
+
),
|
|
70
|
+
'favourite-empty': (
|
|
71
|
+
<path d="M12 20.2l-7-6.6a4.3 4.3 0 0 1 6.1-6l.9 1 .9-1a4.3 4.3 0 0 1 6.1 6z" />
|
|
72
|
+
),
|
|
73
|
+
// The only filled glyph in the set: an outline heart already means "not
|
|
74
|
+
// favourited", so the pair has to differ by fill rather than by shape.
|
|
75
|
+
'favourite-full': (
|
|
76
|
+
<path
|
|
77
|
+
fill="currentColor"
|
|
78
|
+
d="M12 20.2l-7-6.6a4.3 4.3 0 0 1 6.1-6l.9 1 .9-1a4.3 4.3 0 0 1 6.1 6z"
|
|
79
|
+
/>
|
|
80
|
+
),
|
|
81
|
+
gift: (
|
|
82
|
+
<>
|
|
83
|
+
<rect x="3.5" y="8" width="17" height="4" rx="1" />
|
|
84
|
+
<path d="M5.2 12v7.5a1 1 0 0 0 1 1h11.6a1 1 0 0 0 1-1V12" />
|
|
85
|
+
<path d="M12 8v12.5" />
|
|
86
|
+
<path d="M12 8H8.9A2.2 2.2 0 1 1 11.1 5.8 2.6 2.6 0 0 1 12 8z" />
|
|
87
|
+
<path d="M12 8h3.1A2.2 2.2 0 1 0 12.9 5.8 2.6 2.6 0 0 0 12 8z" />
|
|
88
|
+
</>
|
|
89
|
+
),
|
|
90
|
+
share: (
|
|
91
|
+
<>
|
|
92
|
+
<circle cx="17.8" cy="5.8" r="2.6" />
|
|
93
|
+
<circle cx="6.2" cy="12" r="2.6" />
|
|
94
|
+
<circle cx="17.8" cy="18.2" r="2.6" />
|
|
95
|
+
<path d="M8.5 10.8l7-3.8M8.5 13.2l7 3.8" />
|
|
96
|
+
</>
|
|
97
|
+
),
|
|
98
|
+
barcode: (
|
|
99
|
+
<path d="M4 5.5v13M7.2 5.5v13M10.4 5.5v13M13.6 5.5v13M16.8 5.5v13M20 5.5v13" />
|
|
100
|
+
),
|
|
101
|
+
image: (
|
|
102
|
+
<>
|
|
103
|
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
|
104
|
+
<circle cx="8.6" cy="9.6" r="1.6" />
|
|
105
|
+
<path d="M4.2 16.8l4.8-4.6 3.8 3.6 2.6-2.4 4.4 4" />
|
|
106
|
+
</>
|
|
107
|
+
),
|
|
108
|
+
|
|
109
|
+
// visibility
|
|
110
|
+
eye: (
|
|
111
|
+
<>
|
|
112
|
+
<path d="M2.8 12S6.6 5.8 12 5.8 21.2 12 21.2 12 17.4 18.2 12 18.2 2.8 12 2.8 12z" />
|
|
113
|
+
<circle cx="12" cy="12" r="3" />
|
|
114
|
+
</>
|
|
115
|
+
),
|
|
116
|
+
'eye-blocked': (
|
|
117
|
+
<>
|
|
118
|
+
<path d="M2.8 12S6.6 5.8 12 5.8 21.2 12 21.2 12 17.4 18.2 12 18.2 2.8 12 2.8 12z" />
|
|
119
|
+
<circle cx="12" cy="12" r="3" />
|
|
120
|
+
<path d="M4.5 19.5l15-15" />
|
|
121
|
+
</>
|
|
122
|
+
),
|
|
123
|
+
user: (
|
|
124
|
+
<>
|
|
125
|
+
<circle cx="12" cy="8.4" r="3.9" />
|
|
126
|
+
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
|
|
127
|
+
</>
|
|
128
|
+
),
|
|
129
|
+
|
|
130
|
+
// miscellaneous
|
|
131
|
+
hamburger: <path d="M4 7h16M4 12h16M4 17h16" />,
|
|
132
|
+
category: (
|
|
133
|
+
<>
|
|
134
|
+
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
|
|
135
|
+
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
|
|
136
|
+
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
|
|
137
|
+
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
|
|
138
|
+
</>
|
|
139
|
+
),
|
|
140
|
+
trash: (
|
|
141
|
+
<>
|
|
142
|
+
<path d="M4 6.5h16" />
|
|
143
|
+
<path d="M9.5 6.5V4.6a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.9" />
|
|
144
|
+
<path d="M6.6 6.5l1 13a1 1 0 0 0 1 .9h6.8a1 1 0 0 0 1-.9l1-13" />
|
|
145
|
+
<path d="M10.4 10v6.6M13.6 10v6.6" />
|
|
146
|
+
</>
|
|
147
|
+
),
|
|
148
|
+
globe: (
|
|
149
|
+
<>
|
|
150
|
+
<circle cx="12" cy="12" r="8.5" />
|
|
151
|
+
<path d="M3.5 12h17" />
|
|
152
|
+
<path d="M12 3.5a15 15 0 0 1 0 17 15 15 0 0 1 0-17z" />
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** What the resolver decided a stored icon value actually is. */
|
|
158
|
+
export type IconSource =
|
|
159
|
+
| { kind: 'svg'; markup: string }
|
|
160
|
+
| { kind: 'url'; url: string }
|
|
161
|
+
| { kind: 'glyph'; key: string; glyph: React.ReactNode }
|
|
162
|
+
| { kind: 'none' };
|
|
163
|
+
|
|
164
|
+
const NO_ICON: IconSource = { kind: 'none' };
|
|
165
|
+
|
|
166
|
+
export const isInlineSvgMarkup = (value: string): boolean =>
|
|
167
|
+
value.includes('<svg') || value.includes('<?xml');
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Already addressable as it stands: a data URI, an absolute or
|
|
171
|
+
* protocol-relative URL, or a root-relative path the app serves itself.
|
|
172
|
+
*/
|
|
173
|
+
const isUrlValue = (value: string): boolean =>
|
|
174
|
+
value.startsWith('data:') ||
|
|
175
|
+
value.startsWith('http://') ||
|
|
176
|
+
value.startsWith('https://') ||
|
|
177
|
+
value.startsWith('//') ||
|
|
178
|
+
value.startsWith('/');
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A bare value that is a media path rather than a catalogue key.
|
|
182
|
+
*
|
|
183
|
+
* This distinction is the whole point of the module. Every bare value used to
|
|
184
|
+
* be prefixed with the image CDN host, so `basket` — the value the picker
|
|
185
|
+
* stores whenever the brand has not uploaded its own SVG — shipped as a
|
|
186
|
+
* request for `https://<cloud>/basket` and painted a broken image. A path
|
|
187
|
+
* announces itself: it has a directory separator, or a file extension.
|
|
188
|
+
*/
|
|
189
|
+
const looksLikeMediaPath = (value: string): boolean =>
|
|
190
|
+
value.includes('/') || /\.[a-z0-9]{2,4}$/i.test(value);
|
|
191
|
+
|
|
192
|
+
/** Markup or URL, whichever the literal is; null when it is neither. */
|
|
193
|
+
const classifyLiteral = (value: string): IconSource | null => {
|
|
194
|
+
if (isInlineSvgMarkup(value)) return { kind: 'svg', markup: value };
|
|
195
|
+
if (isUrlValue(value)) return { kind: 'url', url: value };
|
|
196
|
+
return null;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The panel stores a plain string, but published JSON has also carried `{url}`
|
|
201
|
+
* wrappers and localized maps, and `icon-block` has always unwrapped both.
|
|
202
|
+
* Kept here so every caller unwraps them the same way.
|
|
203
|
+
*/
|
|
204
|
+
const toIconString = (value: unknown): string => {
|
|
205
|
+
if (typeof value === 'string') return value.trim();
|
|
206
|
+
if (!value || typeof value !== 'object') return '';
|
|
207
|
+
|
|
208
|
+
const record = value as Record<string, unknown>;
|
|
209
|
+
if (typeof record.url === 'string') return record.url.trim();
|
|
210
|
+
|
|
211
|
+
const firstString = Object.values(record).find((v) => typeof v === 'string');
|
|
212
|
+
return firstString ? (firstString as string).trim() : '';
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Answers what to draw for a stored icon value, in this order:
|
|
217
|
+
*
|
|
218
|
+
* inline SVG markup -> data:/http(s)/root-relative URL
|
|
219
|
+
* -> a key the brand uploaded into theme settings
|
|
220
|
+
* -> a key in the inline catalogue
|
|
221
|
+
* -> a genuine media path on the image CDN
|
|
222
|
+
* -> nothing.
|
|
223
|
+
*
|
|
224
|
+
* Theme settings beat the catalogue because that upload IS the brand's
|
|
225
|
+
* override of the default icon; the catalogue is what a brand that never
|
|
226
|
+
* opened the settings page falls back to. The CDN branch is last, and now runs
|
|
227
|
+
* only for values that look like paths.
|
|
228
|
+
*
|
|
229
|
+
* `themeSettings.icons` holds raw SVG markup rather than URLs (the settings
|
|
230
|
+
* page injects it with `dangerouslySetInnerHTML`), but a brand that stored a
|
|
231
|
+
* URL there still resolves, because the stored value is classified rather than
|
|
232
|
+
* assumed.
|
|
233
|
+
*/
|
|
234
|
+
export const resolveIconSource = (
|
|
235
|
+
value: unknown,
|
|
236
|
+
themeSettings?: Record<string, unknown> | null
|
|
237
|
+
): IconSource => {
|
|
238
|
+
const raw = toIconString(value);
|
|
239
|
+
if (!raw) return NO_ICON;
|
|
240
|
+
|
|
241
|
+
const literal = classifyLiteral(raw);
|
|
242
|
+
if (literal) return literal;
|
|
243
|
+
|
|
244
|
+
const uploaded = themeSettings?.icons as Record<string, unknown> | undefined;
|
|
245
|
+
if (uploaded && typeof uploaded === 'object') {
|
|
246
|
+
const stored = toIconString(uploaded[raw]);
|
|
247
|
+
// A theme-settings entry that is itself a bare key is not an icon; falling
|
|
248
|
+
// through to the catalogue draws something rather than nothing.
|
|
249
|
+
const storedSource = stored ? classifyLiteral(stored) : null;
|
|
250
|
+
if (storedSource) return storedSource;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const glyph = ICON_GLYPHS[raw];
|
|
254
|
+
if (glyph) return { kind: 'glyph', key: raw, glyph };
|
|
255
|
+
|
|
256
|
+
if (looksLikeMediaPath(raw)) {
|
|
257
|
+
const cloudName = process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME?.trim();
|
|
258
|
+
if (cloudName) {
|
|
259
|
+
return {
|
|
260
|
+
kind: 'url',
|
|
261
|
+
url: `https://${cloudName}/${raw.replace(/^\/+/, '')}`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return NO_ICON;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export interface IconGlyphProps
|
|
270
|
+
extends Omit<React.SVGProps<SVGSVGElement>, 'children'> {
|
|
271
|
+
/** A catalogue key, or the node a resolved `glyph` source carries. */
|
|
272
|
+
name?: string;
|
|
273
|
+
glyph?: React.ReactNode;
|
|
274
|
+
size?: number | string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Draws one catalogue glyph at the set's shared recipe.
|
|
279
|
+
*
|
|
280
|
+
* `width`/`height` are attributes rather than CSS so the icon has an intrinsic
|
|
281
|
+
* size before any stylesheet arrives; a merchant who sizes the block still
|
|
282
|
+
* wins, because the generated rule lands on the wrapper around it.
|
|
283
|
+
*/
|
|
284
|
+
export const IconGlyph = ({
|
|
285
|
+
name,
|
|
286
|
+
glyph,
|
|
287
|
+
size = 24,
|
|
288
|
+
strokeWidth = 1.5,
|
|
289
|
+
...rest
|
|
290
|
+
}: IconGlyphProps) => {
|
|
291
|
+
const node = glyph ?? (name ? ICON_GLYPHS[name] : null);
|
|
292
|
+
if (!node) return null;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<svg
|
|
296
|
+
width={size}
|
|
297
|
+
height={size}
|
|
298
|
+
viewBox="0 0 24 24"
|
|
299
|
+
fill="none"
|
|
300
|
+
stroke="currentColor"
|
|
301
|
+
strokeWidth={strokeWidth}
|
|
302
|
+
strokeLinecap="round"
|
|
303
|
+
strokeLinejoin="round"
|
|
304
|
+
aria-hidden="true"
|
|
305
|
+
focusable="false"
|
|
306
|
+
{...rest}
|
|
307
|
+
>
|
|
308
|
+
{node}
|
|
309
|
+
</svg>
|
|
310
|
+
);
|
|
311
|
+
};
|