@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,80 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { BlockRendererProps } from './block-renderer-registry';
5
+ import { DesignerPlaceholder } from './product-block-utils';
6
+ import { renderComposedChildren } from './render-composed-children';
7
+
8
+ /**
9
+ * A plain band a designer fills with other blocks: the header's top row, a
10
+ * free-shipping strip above the basket, the card a campaign sits in.
11
+ *
12
+ * Everything a container LOOKS like is authored as style — the descriptor
13
+ * offers background, the four paddings, border width/colour and radius, and
14
+ * the advanced controls reach it like any other block. Everything a container
15
+ * CONTAINS is authored as child blocks. That leaves this component with one
16
+ * job, rendering those children, and nothing to read: the descriptor declares
17
+ * no properties and no parts, and every style key it declares is real CSS the
18
+ * generated sheet already applies, so `block.styles` is never touched here.
19
+ *
20
+ * Properties: none.
21
+ * Parts: none.
22
+ */
23
+ const ContainerBlock = ({
24
+ block,
25
+ placeholderId,
26
+ sectionId,
27
+ isDesigner = false,
28
+ selectedBlockId,
29
+ currentBreakpoint = 'desktop'
30
+ }: BlockRendererProps) => {
31
+ /*
32
+ * Rendered with NO element of its own — the children go straight into the
33
+ * block wrapper.
34
+ *
35
+ * The box the merchant paints already exists. `collectBlockCSS` routes a
36
+ * block's root styles to `[data-block-id]`, which is the WithDesignerFeatures
37
+ * div around this component, present on the live storefront exactly as it is
38
+ * on the canvas and always a real `position: relative` box. So the
39
+ * background, paddings, border and radius have something to paint on before
40
+ * this component renders anything at all.
41
+ *
42
+ * A box drawn here would be a SECOND box inside the painted one, and the
43
+ * damage is not cosmetic: an authored `display: flex` / `grid` — with its
44
+ * `gap` and `grid-template-columns` — also lands on the wrapper, so all the
45
+ * children would sit inside one single flex item and the layout controls
46
+ * would look inert.
47
+ *
48
+ * `group` avoids that with `display: contents` because it still needs an
49
+ * element: its `tag` property can make it an `<a>` or a `<form>`. A container
50
+ * has no properties at all, so there is nothing for an element to carry,
51
+ * while the element itself would still be counted by `:nth-child()` and by
52
+ * any `[data-block-id] > *` rule written against the wrapper — box or no box.
53
+ * Adding none is the honest version of the same trick.
54
+ */
55
+ const children = renderComposedChildren({
56
+ blocks: block.blocks,
57
+ placeholderId,
58
+ sectionId,
59
+ isDesigner,
60
+ selectedBlockId,
61
+ currentBreakpoint
62
+ });
63
+
64
+ // Empty is a legitimate published state — a container with only a background
65
+ // and padding is a band or a spacer, and the wrapper paints it whatever this
66
+ // returns. On the canvas that same band is invisible until a padding is set,
67
+ // so adding one has to say what it is waiting for.
68
+ if (children.length === 0) {
69
+ return isDesigner ? (
70
+ <DesignerPlaceholder
71
+ label="Container (drop blocks here)"
72
+ attribute="data-container-placeholder"
73
+ />
74
+ ) : null;
75
+ }
76
+
77
+ return <>{children}</>;
78
+ };
79
+
80
+ export default ContainerBlock;
@@ -0,0 +1,232 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import { DesignerPlaceholder } from './product-block-utils';
8
+ import { renderComposedChildren } from './render-composed-children';
9
+
10
+ /**
11
+ * One authored string, read through both shapes the editor can store it in.
12
+ *
13
+ * A `localetext` control writes a LOCALE map (`{ 'en-us': …, 'tr-tr': … }`)
14
+ * while `getDefaultValue` seeds the same key as a BREAKPOINT bucket
15
+ * (`{ desktop: …, mobile: … }`) — so a hover showcase item that was never
16
+ * edited and one that was carry the same key in two different shapes. Locale
17
+ * wins when the map has one, otherwise it resolves as an ordinary responsive
18
+ * value.
19
+ *
20
+ * Exported so `hover-showcase-section.tsx` can be repointed at it: the section
21
+ * carries a byte-identical private copy today (:53), and a third copy of a
22
+ * reader is a third place for the two shapes to be resolved differently.
23
+ */
24
+ export const resolveLocalizedBlockText = (
25
+ value: unknown,
26
+ locale: string,
27
+ defaultLocale: string,
28
+ currentBreakpoint: string,
29
+ fallback = ''
30
+ ): string => {
31
+ if (value == null) return fallback;
32
+
33
+ if (typeof value === 'object' && !Array.isArray(value)) {
34
+ const localized = value as Record<string, unknown>;
35
+
36
+ if (localized[locale] != null) return String(localized[locale]);
37
+ if (localized[defaultLocale] != null)
38
+ return String(localized[defaultLocale]);
39
+
40
+ const responsive = getResponsiveValue(value, currentBreakpoint);
41
+ if (responsive != null && typeof responsive !== 'object') {
42
+ return String(responsive);
43
+ }
44
+
45
+ const firstValue = Object.values(localized).find((item) => item != null);
46
+ if (firstValue != null) return String(firstValue);
47
+ }
48
+
49
+ const responsive = getResponsiveValue(value, currentBreakpoint, fallback);
50
+ return String(responsive ?? fallback);
51
+ };
52
+
53
+ /**
54
+ * One entry of a hover showcase: a title, an optional subtitle, and whatever
55
+ * media the author dropped inside it.
56
+ *
57
+ * `hover-showcase-section.tsx` draws these itself, by type, never through
58
+ * ThemeBlock (:122). This renderer exists so the same stored block also draws
59
+ * when it sits anywhere else, and it draws the item's WHOLE content stacked —
60
+ * which is what the section's mobile arm shows. The desktop split pane and the
61
+ * cross-fade between entries are the SECTION's, not the block's: both are
62
+ * coordination across siblings (which entry is active), and a single block has
63
+ * no siblings to coordinate with.
64
+ *
65
+ * Parts: title, link, subtitle.
66
+ */
67
+ const HoverShowcaseItemBlock = ({
68
+ block,
69
+ placeholderId,
70
+ sectionId,
71
+ selectedBlockId = null,
72
+ currentBreakpoint = 'desktop',
73
+ isDesigner = false
74
+ }: BlockRendererProps) => {
75
+ const { locale } = useLocalization();
76
+ const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
77
+
78
+ /**
79
+ * Properties first, styles second — for one key, in two places.
80
+ *
81
+ * The descriptor declares title/subtitle/link under `properties` but without
82
+ * `target: 'property'`, and the panel defaults an untargeted field to
83
+ * `'style'` (generic-properties-panel.tsx:471). So `getDefaultValue` SEEDS
84
+ * these into `block.properties` while every edit the merchant makes LANDS in
85
+ * `block.styles`. Both locations therefore hold real authored values today.
86
+ *
87
+ * Properties still wins, and the asymmetry is deliberate. Preferring styles
88
+ * would make today's edits visible — and would then let a value typed BEFORE
89
+ * the descriptor is fixed shadow every edit made after it, permanently, with
90
+ * no control left that could clear it. Preferring properties costs only what
91
+ * the section already costs (an edit that landed in styles stays ignored
92
+ * while a seed sits in properties) and self-heals the moment the panel starts
93
+ * writing to the right place.
94
+ *
95
+ * The seeded value is also why this cannot be a `??` chain: the seed writes
96
+ * `link: ''`, and an empty string is not nullish, so a nullish fallback would
97
+ * let the untouched seed shadow the URL the merchant actually typed. Emptiness
98
+ * is what makes a location absent here, not nullishness.
99
+ */
100
+ const authored = (key: string): string => {
101
+ const fromProperties = resolveLocalizedBlockText(
102
+ block.properties?.[key],
103
+ locale,
104
+ defaultLocale,
105
+ currentBreakpoint
106
+ );
107
+ if (fromProperties) return fromProperties;
108
+
109
+ return resolveLocalizedBlockText(
110
+ block.styles?.[key],
111
+ locale,
112
+ defaultLocale,
113
+ currentBreakpoint
114
+ );
115
+ };
116
+
117
+ const title = authored('title');
118
+ const subtitle = authored('subtitle');
119
+ const link = authored('link');
120
+
121
+ /**
122
+ * The two style keys this renderer is allowed to read, because they are the
123
+ * only two it CAN read: `title-font-size` and `subtitle-font-size` are not
124
+ * CSS properties, so the generated sheet emits them verbatim as declarations
125
+ * the browser drops. Nothing else from `block.styles` is touched — every real
126
+ * property, `padding-top`/`padding-bottom` included, already lands on the
127
+ * `[data-block-id]` wrapper as a rule.
128
+ *
129
+ * Returns undefined rather than a default size on purpose. The panel also
130
+ * offers the advanced typography family, so a merchant can set a real
131
+ * `font-size` on this block; a default inlined here would outrank that rule
132
+ * (and its media queries) for everyone who never touched these two controls.
133
+ */
134
+ const fontSizeOverride = (key: string): string | undefined => {
135
+ const raw = getResponsiveValue(block.styles?.[key], currentBreakpoint, '');
136
+ const text = String(raw ?? '').trim();
137
+
138
+ return text || undefined;
139
+ };
140
+
141
+ const titleFontSize = fontSizeOverride('title-font-size');
142
+ const subtitleFontSize = fontSizeOverride('subtitle-font-size');
143
+
144
+ const children = renderComposedChildren({
145
+ blocks: block.blocks,
146
+ placeholderId,
147
+ sectionId,
148
+ isDesigner,
149
+ selectedBlockId,
150
+ currentBreakpoint
151
+ });
152
+
153
+ /**
154
+ * No invented title. The section falls back to `Item ${index + 1}`, which it
155
+ * can only do because it is looping — a standalone block has no index, and
156
+ * the alternatives are worse than nothing: `block.label` is the layer-tree
157
+ * name ("Hover Showcase Item") and would ship that to a shopper as a heading.
158
+ * So an item with no authored text and nothing inside draws the labelled
159
+ * designer band and stays invisible on the storefront.
160
+ */
161
+ if (!title && !subtitle && children.length === 0) {
162
+ return isDesigner ? (
163
+ <DesignerPlaceholder
164
+ label="Hover showcase item (set a title, or add a block inside)"
165
+ attribute="data-hover-showcase-item"
166
+ />
167
+ ) : null;
168
+ }
169
+
170
+ // A click in the designer selects the block; following the href would
171
+ // navigate the preview iframe away from the page being edited.
172
+ const handleLinkClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
173
+ if (isDesigner) event.preventDefault();
174
+ };
175
+
176
+ const heading = title ? (
177
+ <h3
178
+ data-hover-showcase-title=""
179
+ {...partAttrs('title')}
180
+ style={{
181
+ margin: 0,
182
+ ...(titleFontSize ? { fontSize: titleFontSize } : null)
183
+ }}
184
+ >
185
+ {title}
186
+ </h3>
187
+ ) : null;
188
+
189
+ /**
190
+ * A fragment, not a box. Every layout property the merchant authors on this
191
+ * item (`display`, `gap`, `grid-template-columns`) is emitted against the
192
+ * `[data-block-id]` wrapper around this component — a box of my own would
193
+ * make the title, the subtitle and the media a single flex item inside it,
194
+ * and the authored layout would have exactly one thing to lay out.
195
+ */
196
+ return (
197
+ <>
198
+ {heading && link ? (
199
+ // Only the title is linked, matching the section. With no title there
200
+ // is nothing to hang the anchor on, so an authored link is simply not
201
+ // reachable — the same as in the section today.
202
+ <a
203
+ href={link}
204
+ data-hover-showcase-link=""
205
+ {...partAttrs('link')}
206
+ onClick={handleLinkClick}
207
+ >
208
+ {heading}
209
+ </a>
210
+ ) : (
211
+ heading
212
+ )}
213
+
214
+ {subtitle ? (
215
+ <p
216
+ data-hover-showcase-subtitle=""
217
+ {...partAttrs('subtitle')}
218
+ style={{
219
+ margin: 0,
220
+ ...(subtitleFontSize ? { fontSize: subtitleFontSize } : null)
221
+ }}
222
+ >
223
+ {subtitle}
224
+ </p>
225
+ ) : null}
226
+
227
+ {children}
228
+ </>
229
+ );
230
+ };
231
+
232
+ export default HoverShowcaseItemBlock;
@@ -1,7 +1,11 @@
1
+ 'use client';
2
+
1
3
  import React from 'react';
2
4
  import { getResponsiveValue } from '../utils';
3
5
  import { BlockRendererProps } from './block-renderer-registry';
4
6
  import { Image } from '@akinon/next/components/image';
7
+ import { useThemeSettingsContext } from '../theme-settings-context';
8
+ import { IconGlyph, resolveIconSource } from './icon-source';
5
9
 
6
10
  const isValidUrlValue = (value: string): boolean => {
7
11
  if (!value) return false;
@@ -19,6 +23,7 @@ const IconBlock = ({
19
23
  block,
20
24
  currentBreakpoint = 'desktop'
21
25
  }: BlockRendererProps) => {
26
+ const themeSettings = useThemeSettingsContext();
22
27
  const iconValue = getResponsiveValue(block.value, currentBreakpoint, '');
23
28
  // An uploaded brand mark announced itself to screen readers as "Icon"
24
29
  // because the alt the panel offers was never read.
@@ -44,13 +49,6 @@ const IconBlock = ({
44
49
  justifyContent: 'center'
45
50
  } as React.CSSProperties;
46
51
 
47
- const isInlineSvg = (value: string): boolean => {
48
- return (
49
- typeof value === 'string' &&
50
- (value.includes('<svg') || value.includes('<?xml'))
51
- );
52
- };
53
-
54
52
  const modifySVGColor = (svgContent: string, color: string) => {
55
53
  if (color === 'currentColor' || !color) return svgContent;
56
54
 
@@ -90,120 +88,21 @@ const IconBlock = ({
90
88
  }
91
89
  };
92
90
 
93
- let iconContent;
94
-
95
- let svgSrc = '';
96
-
97
- if (iconValue) {
98
- if (typeof iconValue === 'string') {
99
- svgSrc = iconValue.trim();
100
- } else if (typeof iconValue === 'object' && iconValue !== null) {
101
- if ('url' in iconValue) {
102
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
- svgSrc = String((iconValue as any).url || '').trim();
104
- } else {
105
- // Try to find a string value in the object (e.g. localized)
106
- const values = Object.values(iconValue);
107
- const stringVal = values.find((v) => typeof v === 'string');
108
- if (stringVal) {
109
- svgSrc = (stringVal as string).trim();
110
- }
111
- }
112
- }
113
-
114
- const isBase64 =
115
- typeof svgSrc === 'string' && svgSrc.startsWith('data:image');
116
- const isSvg = isInlineSvg(svgSrc);
117
- const isAbsolutePath = svgSrc.startsWith('/');
118
-
119
- if (
120
- !isBase64 &&
121
- !isSvg &&
122
- svgSrc &&
123
- !isAbsolutePath &&
124
- !svgSrc.startsWith('http') &&
125
- !svgSrc.startsWith('//')
126
- ) {
127
- const cloudName = process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME?.trim();
128
- svgSrc = cloudName
129
- ? `https://${cloudName}/${svgSrc.replace(/^\/+/, '')}`
130
- : '';
131
- }
132
-
133
- if (
134
- isBase64 &&
135
- svgSrc.includes('data:image/svg+xml;base64,') &&
136
- iconColor !== 'currentColor'
137
- ) {
138
- svgSrc = processBase64SVG(svgSrc, iconColor);
139
- }
140
-
141
- if (svgSrc && svgSrc.trim() !== '') {
142
- // Check if it's inline SVG content
143
- if (isInlineSvg(svgSrc)) {
144
- // Apply color modification to inline SVG
145
- const coloredSvg =
146
- iconColor !== 'currentColor'
147
- ? modifySVGColor(svgSrc, iconColor)
148
- : svgSrc;
149
-
150
- iconContent = (
151
- <div
152
- style={{
153
- ...iconStyles,
154
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
- ['--svg-size' as any]: iconSize + 'px'
156
- }}
157
- className="[&>svg]:w-[var(--svg-size)] [&>svg]:h-[var(--svg-size)]"
158
- dangerouslySetInnerHTML={{ __html: coloredSvg }}
159
- />
160
- );
161
- } else {
162
- // It's a URL or base64, use img tag
163
- if (isValidUrlValue(svgSrc)) {
164
- iconContent = (
165
- <div style={iconStyles}>
166
- <Image
167
- src={svgSrc}
168
- width={Number(iconSize)}
169
- height={Number(iconSize)}
170
- alt={iconAlt}
171
- />
172
- </div>
173
- );
174
- }
175
- }
176
- } else {
177
- iconContent = (
178
- <div
179
- style={{
180
- ...iconStyles,
181
- border: '1px dashed #ccc',
182
- borderRadius: '4px',
183
- backgroundColor: '#f9f9f9'
184
- }}
185
- >
186
- <span style={{ fontSize: '10px', color: '#999' }}>No icon</span>
187
- </div>
188
- );
189
- }
91
+ const noIcon = (
92
+ <div
93
+ style={{
94
+ ...iconStyles,
95
+ border: '1px dashed #ccc',
96
+ borderRadius: '4px',
97
+ backgroundColor: '#f9f9f9'
98
+ }}
99
+ >
100
+ <span style={{ fontSize: '10px', color: '#999' }}>No icon</span>
101
+ </div>
102
+ );
190
103
 
191
- if (!iconContent) {
192
- iconContent = (
193
- <div
194
- style={{
195
- ...iconStyles,
196
- border: '1px dashed #ccc',
197
- borderRadius: '4px',
198
- backgroundColor: '#f9f9f9'
199
- }}
200
- >
201
- <span style={{ fontSize: '10px', color: '#999' }}>No icon</span>
202
- </div>
203
- );
204
- }
205
- } else {
206
- iconContent = (
104
+ if (!iconValue) {
105
+ return (
207
106
  <div
208
107
  style={{
209
108
  ...iconStyles,
@@ -234,7 +133,69 @@ const IconBlock = ({
234
133
  );
235
134
  }
236
135
 
237
- return iconContent;
136
+ // What the stored value actually is — markup, a URL, a catalogue key, or
137
+ // nothing. The CDN prefix used to be applied to every bare value, which is
138
+ // why a picked catalogue key such as `basket` shipped as a broken image.
139
+ const source = resolveIconSource(iconValue, themeSettings);
140
+
141
+ if (source.kind === 'svg') {
142
+ const coloredSvg =
143
+ iconColor !== 'currentColor'
144
+ ? modifySVGColor(source.markup, iconColor)
145
+ : source.markup;
146
+
147
+ return (
148
+ <div
149
+ style={{
150
+ ...iconStyles,
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
+ ['--svg-size' as any]: iconSize + 'px'
153
+ }}
154
+ className="[&>svg]:w-[var(--svg-size)] [&>svg]:h-[var(--svg-size)]"
155
+ dangerouslySetInnerHTML={{ __html: coloredSvg }}
156
+ />
157
+ );
158
+ }
159
+
160
+ if (source.kind === 'glyph') {
161
+ return (
162
+ <div
163
+ style={{
164
+ ...iconStyles,
165
+ // The glyphs are drawn with `currentColor`, so the colour the panel
166
+ // stores is inherited rather than rewritten into the markup.
167
+ ...(iconColor && iconColor !== 'currentColor'
168
+ ? { color: iconColor }
169
+ : null)
170
+ }}
171
+ >
172
+ <IconGlyph glyph={source.glyph} size={Number(iconSize) || 24} />
173
+ </div>
174
+ );
175
+ }
176
+
177
+ if (source.kind === 'url') {
178
+ const src =
179
+ source.url.includes('data:image/svg+xml;base64,') &&
180
+ iconColor !== 'currentColor'
181
+ ? processBase64SVG(source.url, iconColor)
182
+ : source.url;
183
+
184
+ if (isValidUrlValue(src)) {
185
+ return (
186
+ <div style={iconStyles}>
187
+ <Image
188
+ src={src}
189
+ width={Number(iconSize)}
190
+ height={Number(iconSize)}
191
+ alt={iconAlt}
192
+ />
193
+ </div>
194
+ );
195
+ }
196
+ }
197
+
198
+ return noIcon;
238
199
  };
239
200
 
240
201
  export default IconBlock;