@akinon/pz-theme 2.0.75-rc.0 → 2.0.75
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 -32
- package/jest/__mocks__/akinon-next-plugin-module.js +19 -3
- package/package.json +4 -4
- package/src/blocks/__tests__/basket-line-layout.test.tsx +174 -5
- package/src/blocks/__tests__/basket-line-price.test.tsx +24 -0
- package/src/blocks/__tests__/basket-offers.test.tsx +96 -1
- package/src/blocks/basket-items-block.tsx +204 -14
- package/src/blocks/basket-summary-block.tsx +223 -59
- package/src/chrome/__tests__/composition-source.test.ts +125 -0
- package/src/chrome/composition-source.ts +37 -10
- package/src/chrome/retrying-widget-data.ts +33 -0
- package/src/chrome/theme-config-source.ts +6 -7
- package/src/load-placeholder-sections.ts +4 -10
- package/src/utils/__tests__/color-alpha.test.ts +29 -0
- package/src/utils/index.ts +39 -3
- package/src/utils/style-resolution.test.ts +6 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getWidgetData } from '@akinon/next/data/server';
|
|
2
|
+
|
|
3
|
+
const RETRY_DELAYS_MS = [100, 250];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The data layer swallows transport errors and resolves with nothing — and a
|
|
7
|
+
* widget that truly does not exist answers with an empty 404 body, which the
|
|
8
|
+
* layer's JSON parse turns into the same nothing. The two cannot be told
|
|
9
|
+
* apart here, so a falsy answer is retried on a short backoff: a flaky
|
|
10
|
+
* socket recovers within a beat and a published composition stops flickering
|
|
11
|
+
* back to the brand markup, while a genuinely missing widget only costs two
|
|
12
|
+
* quick extra reads before the caller keeps the project's own chrome.
|
|
13
|
+
*
|
|
14
|
+
* Only for the low-volume chrome reads (placeholder widgets, theme-config).
|
|
15
|
+
* Per-section data fetches must not use this: a regular-widget miss is the
|
|
16
|
+
* NORMAL path for a collection section, and retrying it would tax every
|
|
17
|
+
* composed page.
|
|
18
|
+
*/
|
|
19
|
+
export const getWidgetDataWithRetry = async <T>(
|
|
20
|
+
slug: string
|
|
21
|
+
): Promise<T | null> => {
|
|
22
|
+
for (let attempt = 0; ; attempt++) {
|
|
23
|
+
const lastAttempt = attempt >= RETRY_DELAYS_MS.length;
|
|
24
|
+
try {
|
|
25
|
+
const data = await getWidgetData<T>({ slug });
|
|
26
|
+
if (data) return data as T;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (lastAttempt) throw error;
|
|
29
|
+
}
|
|
30
|
+
if (lastAttempt) return null;
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS_MS[attempt]));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { getWidgetDataWithRetry } from './retrying-widget-data';
|
|
3
3
|
|
|
4
4
|
import { readThemeModals, ThemeModal } from '../overlays/modal-registry';
|
|
5
5
|
|
|
@@ -86,12 +86,11 @@ export const readThemeConfig = (data: ThemeConfigWidget): ThemeConfig => {
|
|
|
86
86
|
|
|
87
87
|
const load = async (): Promise<ThemeConfig> => {
|
|
88
88
|
try {
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}>({ slug: 'theme-config' });
|
|
89
|
+
// Retried on a short backoff: a transient miss here silently drops every
|
|
90
|
+
// data source and theme setting on the page.
|
|
91
|
+
const data = await getWidgetDataWithRetry<ThemeConfigWidget>(
|
|
92
|
+
'theme-config'
|
|
93
|
+
);
|
|
95
94
|
|
|
96
95
|
return readThemeConfig(data as ThemeConfigWidget);
|
|
97
96
|
} catch {
|
|
@@ -7,6 +7,7 @@ import { ServerVariables } from '@akinon/next/utils/server-variables';
|
|
|
7
7
|
import { Section } from './theme-section';
|
|
8
8
|
import { devWarn } from './utils/dev-warn';
|
|
9
9
|
import { validateThemeSectionsInDev } from './schemas/theme-schema';
|
|
10
|
+
import { getPlaceholderWidget } from './chrome/composition-source';
|
|
10
11
|
import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
|
|
11
12
|
|
|
12
13
|
const { getWidgetData } = serverData;
|
|
@@ -57,13 +58,6 @@ const getWidgetSchemaData = async <T,>(args: {
|
|
|
57
58
|
return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
|
|
58
59
|
};
|
|
59
60
|
|
|
60
|
-
type ThemePlaceholderData = {
|
|
61
|
-
theme_editor_placeholder: Array<{
|
|
62
|
-
section_slug: string;
|
|
63
|
-
order: number;
|
|
64
|
-
}>;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
61
|
export interface LoadedPlaceholder {
|
|
68
62
|
sections: Section[];
|
|
69
63
|
placeholderId: string;
|
|
@@ -94,9 +88,9 @@ export async function loadPlaceholderSections(
|
|
|
94
88
|
|
|
95
89
|
let data: { slug?: string; attributes?: { theme_editor_placeholder?: unknown; [key: string]: unknown } } | null = null;
|
|
96
90
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
// Shared with ChromeSlot's composed/uncomposed decision — see
|
|
92
|
+
// getPlaceholderWidget for why the two must read one fetch.
|
|
93
|
+
data = (await getPlaceholderWidget(slug)) as typeof data;
|
|
100
94
|
} catch (placeholderError) {
|
|
101
95
|
console.warn(
|
|
102
96
|
`Theme placeholder widget "${slug}" was not found. Rendering empty placeholder.`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { colorToRgba } from '../index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The theme CSS generator routes every background-color through
|
|
5
|
+
* `colorToRgba(value, backgroundAlpha)` with backgroundAlpha defaulting to 1.
|
|
6
|
+
* A colour the author already gave an alpha must keep it — rebuilding
|
|
7
|
+
* rgba(0,0,0,0.07) used to print rgba(0, 0, 0, 1) and turn a 7% tint into
|
|
8
|
+
* solid black.
|
|
9
|
+
*/
|
|
10
|
+
describe('authored rgba alpha survives the background pass', () => {
|
|
11
|
+
it('keeps a 7% tint at the default background opacity', () => {
|
|
12
|
+
expect(colorToRgba('rgba(0, 0, 0, 0.07)', 1)).toBe('rgba(0, 0, 0, 0.07)');
|
|
13
|
+
expect(colorToRgba('rgb(0 0 0 / 7%)', 1)).toBe('rgba(0, 0, 0, 0.07)');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('multiplies an authored alpha with a background-opacity below 100', () => {
|
|
17
|
+
expect(colorToRgba('rgba(0, 0, 0, 0.5)', 0.5)).toBe('rgba(0, 0, 0, 0.25)');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps the alpha channel of an 8-digit hex', () => {
|
|
21
|
+
expect(colorToRgba('#00000012', 1)).toBe('rgba(0, 0, 0, 0.0706)');
|
|
22
|
+
expect(colorToRgba('#0003', 1)).toBe('rgba(0, 0, 0, 0.2)');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('still applies the requested alpha to opaque colours', () => {
|
|
26
|
+
expect(colorToRgba('#000000', 0.4)).toBe('rgba(0, 0, 0, 0.4)');
|
|
27
|
+
expect(colorToRgba('rgb(10, 20, 30)', 0.5)).toBe('rgba(10, 20, 30, 0.5)');
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -115,6 +115,28 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
|
|
|
115
115
|
}
|
|
116
116
|
const short = normalized.match(/^#([0-9a-f]{3})$/i);
|
|
117
117
|
const long = normalized.match(/^#([0-9a-f]{6})$/i);
|
|
118
|
+
// #RRGGBBAA / #RGBA: the authored alpha channel is kept and multiplied
|
|
119
|
+
// with the requested one, like the rgba() branches below.
|
|
120
|
+
const longAlpha = normalized.match(/^#([0-9a-f]{8})$/i);
|
|
121
|
+
const shortAlpha = normalized.match(/^#([0-9a-f]{4})$/i);
|
|
122
|
+
|
|
123
|
+
if (longAlpha) {
|
|
124
|
+
const h = longAlpha[1];
|
|
125
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
126
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
127
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
128
|
+
const authored = parseInt(h.slice(6, 8), 16) / 255;
|
|
129
|
+
return `rgba(${r}, ${g}, ${b}, ${Number((authored * alpha).toFixed(4))})`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (shortAlpha) {
|
|
133
|
+
const h = shortAlpha[1];
|
|
134
|
+
const r = parseInt(h[0] + h[0], 16);
|
|
135
|
+
const g = parseInt(h[1] + h[1], 16);
|
|
136
|
+
const b = parseInt(h[2] + h[2], 16);
|
|
137
|
+
const authored = parseInt(h[3] + h[3], 16) / 255;
|
|
138
|
+
return `rgba(${r}, ${g}, ${b}, ${Number((authored * alpha).toFixed(4))})`;
|
|
139
|
+
}
|
|
118
140
|
|
|
119
141
|
if (short) {
|
|
120
142
|
const h = short[1];
|
|
@@ -132,7 +154,10 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
|
|
|
132
154
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
133
155
|
}
|
|
134
156
|
|
|
135
|
-
// rgb(12, 34, 56) or rgba(12, 34, 56, 0.5)
|
|
157
|
+
// rgb(12, 34, 56) or rgba(12, 34, 56, 0.5). An alpha the author already
|
|
158
|
+
// put into the colour is kept and multiplied with the requested one —
|
|
159
|
+
// rebuilding rgba(0,0,0,0.07) at the default background opacity used to
|
|
160
|
+
// print rgba(0, 0, 0, 1) and turn a 7% tint solid black.
|
|
136
161
|
const commaRgb = normalized.match(
|
|
137
162
|
/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*([0-9.]+))?\s*\)$/i
|
|
138
163
|
);
|
|
@@ -141,7 +166,10 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
|
|
|
141
166
|
const g = Number(commaRgb[2]);
|
|
142
167
|
const b = Number(commaRgb[3]);
|
|
143
168
|
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
|
|
144
|
-
|
|
169
|
+
const authored =
|
|
170
|
+
commaRgb[4] !== undefined ? Number(commaRgb[4]) : 1;
|
|
171
|
+
const effective = Number.isNaN(authored) ? alpha : authored * alpha;
|
|
172
|
+
return `rgba(${r}, ${g}, ${b}, ${effective})`;
|
|
145
173
|
}
|
|
146
174
|
|
|
147
175
|
// rgb(12 34 56) or rgb(12 34 56 / 0.5)
|
|
@@ -153,7 +181,15 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
|
|
|
153
181
|
const g = Number(spaceRgb[2]);
|
|
154
182
|
const b = Number(spaceRgb[3]);
|
|
155
183
|
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
|
|
156
|
-
|
|
184
|
+
const rawAuthored = spaceRgb[4];
|
|
185
|
+
const authored =
|
|
186
|
+
rawAuthored === undefined
|
|
187
|
+
? 1
|
|
188
|
+
: rawAuthored.endsWith('%')
|
|
189
|
+
? Number(rawAuthored.slice(0, -1)) / 100
|
|
190
|
+
: Number(rawAuthored);
|
|
191
|
+
const effective = Number.isNaN(authored) ? alpha : authored * alpha;
|
|
192
|
+
return `rgba(${r}, ${g}, ${b}, ${effective})`;
|
|
157
193
|
}
|
|
158
194
|
|
|
159
195
|
return null;
|
|
@@ -59,14 +59,18 @@ describe('colorToRgba', () => {
|
|
|
59
59
|
expect(colorToRgba('rgb(12 34 56)', 0.5)).toBe('rgba(12, 34, 56, 0.5)');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('returns null for formats it cannot parse (named, var,
|
|
62
|
+
it('returns null for formats it cannot parse (named, var, hsl)', () => {
|
|
63
63
|
// These return null -> the caller silently drops the alpha. Locking the
|
|
64
64
|
// current behavior so a future change here is a conscious decision.
|
|
65
65
|
expect(colorToRgba('red', 0.5)).toBeNull();
|
|
66
66
|
expect(colorToRgba('var(--theme-primary)', 0.5)).toBeNull();
|
|
67
|
-
expect(colorToRgba('#11223344', 0.5)).toBeNull();
|
|
68
67
|
expect(colorToRgba('hsl(0, 100%, 50%)', 0.5)).toBeNull();
|
|
69
68
|
});
|
|
69
|
+
|
|
70
|
+
it('multiplies the alpha channel of an 8-digit hex with the requested one', () => {
|
|
71
|
+
// 0x44 = 68/255 ≈ 0.2667; halved ≈ 0.1333.
|
|
72
|
+
expect(colorToRgba('#11223344', 0.5)).toBe('rgba(17, 34, 51, 0.1333)');
|
|
73
|
+
});
|
|
70
74
|
});
|
|
71
75
|
|
|
72
76
|
describe('applyAlphaToColor', () => {
|