@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,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
+ };