@akinon/pz-theme 2.0.167-beta.0 → 2.0.167
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
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.167
|
|
3
|
+
## 2.0.167
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.167
|
|
9
|
-
- @akinon/theme-contract@2.0.167
|
|
7
|
+
- e0fc41c3: ZERO-5511: Native empty state for the order-list block. A child group marked with role `order-list-empty` is drawn in place of the whole block (title included) once a signed-in shopper turns out to have no orders, so the heading, the explanation, the call-to-action links and product suggestions are authored in the panel instead of being switched on with custom CSS. `previewEmptyState` draws it on the canvas (editor only). The empty message (`emptyLabel`) and the `data-order-list-state="empty"` wrapper stay as they were, and the storefront now shows them for an empty list even when a row layout is set.
|
|
8
|
+
- @akinon/next@2.0.167
|
|
9
|
+
- @akinon/theme-contract@2.0.167
|
|
10
10
|
|
|
11
11
|
## 2.0.166
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.167
|
|
4
|
+
"version": "2.0.167",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.167
|
|
11
|
+
"@akinon/next": "2.0.167",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.167
|
|
16
|
+
"@akinon/theme-contract": "2.0.167",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.167
|
|
24
|
+
"@akinon/next": "2.0.167",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import OrderListBlock from '../order-list-block';
|
|
4
|
+
|
|
5
|
+
const state: {
|
|
6
|
+
status: string;
|
|
7
|
+
query: { data?: unknown; isLoading: boolean };
|
|
8
|
+
hasTemplate: boolean;
|
|
9
|
+
} = {
|
|
10
|
+
status: 'authenticated',
|
|
11
|
+
query: { data: { count: 0, results: [] }, isLoading: false },
|
|
12
|
+
hasTemplate: false
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
jest.mock('../item-template', () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
hasItemTemplate: () => state.hasTemplate,
|
|
18
|
+
// Like the real list on the storefront: nothing to repeat, nothing drawn.
|
|
19
|
+
ItemTemplateList: ({ items }: { items: unknown[] }) =>
|
|
20
|
+
items.length > 0 ? <li data-testid="template-row" /> : null
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock('../../theme-block', () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
26
|
+
<div data-testid="theme-block" data-block-id={block.id} />
|
|
27
|
+
)
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.mock('next-auth/react', () => ({
|
|
31
|
+
useSession: () => ({ data: null, status: state.status })
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
35
|
+
__esModule: true,
|
|
36
|
+
useLocalization: () => ({ locale: 'tr', currency: 'try' })
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
jest.mock('@akinon/next/data/client/account', () => ({
|
|
40
|
+
__esModule: true,
|
|
41
|
+
useGetOrdersQuery: () => state.query
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const ORDER = {
|
|
45
|
+
id: 42,
|
|
46
|
+
number: 'ORD-42',
|
|
47
|
+
amount: '100.00',
|
|
48
|
+
created_date: '2026-09-01T10:00:00Z',
|
|
49
|
+
status: { value: '550', label: 'Delivered' },
|
|
50
|
+
orderitem_set: []
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const emptyLayout = (overrides: Record<string, unknown> = {}) => ({
|
|
54
|
+
id: 'empty-layout',
|
|
55
|
+
type: 'group',
|
|
56
|
+
properties: { role: { desktop: 'order-list-empty' } },
|
|
57
|
+
styles: {},
|
|
58
|
+
blocks: [],
|
|
59
|
+
...overrides
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const block = (
|
|
63
|
+
properties: Record<string, unknown> = {},
|
|
64
|
+
blocks: unknown[] = [emptyLayout()]
|
|
65
|
+
) =>
|
|
66
|
+
({
|
|
67
|
+
id: 'order-list-1',
|
|
68
|
+
type: 'order-list',
|
|
69
|
+
properties: {
|
|
70
|
+
showTitle: { desktop: true },
|
|
71
|
+
title: { desktop: 'My orders' },
|
|
72
|
+
...properties
|
|
73
|
+
},
|
|
74
|
+
styles: {},
|
|
75
|
+
blocks
|
|
76
|
+
} as never);
|
|
77
|
+
|
|
78
|
+
const renderBlock = (
|
|
79
|
+
props: Record<string, unknown> = {},
|
|
80
|
+
isDesigner = false,
|
|
81
|
+
blocks?: unknown[]
|
|
82
|
+
) =>
|
|
83
|
+
render(
|
|
84
|
+
<OrderListBlock
|
|
85
|
+
block={block(props, blocks)}
|
|
86
|
+
placeholderId="p1"
|
|
87
|
+
sectionId="s1"
|
|
88
|
+
isDesigner={isDesigner}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
state.status = 'authenticated';
|
|
94
|
+
state.query = { data: { count: 0, results: [] }, isLoading: false };
|
|
95
|
+
state.hasTemplate = false;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('order list empty state', () => {
|
|
99
|
+
it('draws the empty-orders layout in place of the block for a shopper without orders', () => {
|
|
100
|
+
const { container } = renderBlock();
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId('theme-block')).toHaveAttribute(
|
|
103
|
+
'data-block-id',
|
|
104
|
+
'empty-layout'
|
|
105
|
+
);
|
|
106
|
+
expect(
|
|
107
|
+
container.querySelector('[data-order-list-state="empty"]')
|
|
108
|
+
).not.toBeNull();
|
|
109
|
+
// The layout carries its own heading; the block title gives way.
|
|
110
|
+
expect(container.textContent).not.toContain('My orders');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('keeps the list when the shopper has orders', () => {
|
|
114
|
+
state.query = { data: { count: 1, results: [ORDER] }, isLoading: false };
|
|
115
|
+
const { container } = renderBlock();
|
|
116
|
+
|
|
117
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
118
|
+
expect(container.querySelector('[data-order-list-item]')).not.toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('claims nothing while the orders are loading or for a guest', () => {
|
|
122
|
+
state.query = { data: undefined, isLoading: true };
|
|
123
|
+
renderBlock();
|
|
124
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
125
|
+
|
|
126
|
+
state.status = 'unauthenticated';
|
|
127
|
+
state.query = { data: undefined, isLoading: false };
|
|
128
|
+
renderBlock();
|
|
129
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('leaves a hidden layout off the storefront and falls back to the empty message', () => {
|
|
133
|
+
const { container } = renderBlock(
|
|
134
|
+
{ emptyLabel: { desktop: 'No orders yet' } },
|
|
135
|
+
false,
|
|
136
|
+
[emptyLayout({ hidden: true })]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
140
|
+
expect(
|
|
141
|
+
container.querySelector('[data-order-list-state="empty"]')?.textContent
|
|
142
|
+
).toBe('No orders yet');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('shows the empty message on the storefront even when a row layout is set', () => {
|
|
146
|
+
state.hasTemplate = true;
|
|
147
|
+
const { container } = renderBlock(
|
|
148
|
+
{ emptyLabel: { desktop: 'No orders yet' } },
|
|
149
|
+
false,
|
|
150
|
+
[]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(container.querySelector('[data-order-list]')?.tagName).toBe('DIV');
|
|
154
|
+
expect(
|
|
155
|
+
container.querySelector('[data-order-list-state="empty"]')?.textContent
|
|
156
|
+
).toBe('No orders yet');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('draws the empty-orders layout on the storefront even when a row layout is set', () => {
|
|
160
|
+
state.hasTemplate = true;
|
|
161
|
+
renderBlock();
|
|
162
|
+
|
|
163
|
+
expect(screen.getByTestId('theme-block')).toHaveAttribute(
|
|
164
|
+
'data-block-id',
|
|
165
|
+
'empty-layout'
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('previews the layout on the canvas only when asked, even with orders', () => {
|
|
170
|
+
state.query = { data: { count: 1, results: [ORDER] }, isLoading: false };
|
|
171
|
+
|
|
172
|
+
renderBlock({}, true);
|
|
173
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
174
|
+
|
|
175
|
+
renderBlock({ previewEmptyState: { desktop: true } }, true);
|
|
176
|
+
expect(screen.getByTestId('theme-block')).toHaveAttribute(
|
|
177
|
+
'data-block-id',
|
|
178
|
+
'empty-layout'
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('never lets the preview toggle empty a live list', () => {
|
|
183
|
+
state.query = { data: { count: 1, results: [ORDER] }, isLoading: false };
|
|
184
|
+
renderBlock({ previewEmptyState: { desktop: true } }, false);
|
|
185
|
+
|
|
186
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('keeps sample rows on the canvas when a row layout is there to design', () => {
|
|
190
|
+
state.hasTemplate = true;
|
|
191
|
+
renderBlock({}, true);
|
|
192
|
+
|
|
193
|
+
expect(screen.queryByTestId('theme-block')).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -11,6 +11,19 @@ import { readMoneyStyle } from './money-style';
|
|
|
11
11
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
12
12
|
import { readToggle } from './product-block-utils';
|
|
13
13
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
|
+
import ThemeBlock, { Block } from '../theme-block';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A child group marked with this role is what a signed-in shopper without
|
|
18
|
+
* orders sees in place of the list — a composition, so the heading, the
|
|
19
|
+
* explanation, the call-to-action links and even product suggestions are
|
|
20
|
+
* authored in the panel instead of being switched on with custom CSS. Same
|
|
21
|
+
* idea as product-grid's `listing-empty` (ZERO-5511).
|
|
22
|
+
*/
|
|
23
|
+
const EMPTY_STATE_ROLE = 'order-list-empty';
|
|
24
|
+
|
|
25
|
+
const readChildRole = (child: Block, breakpoint: string): string =>
|
|
26
|
+
String(getResponsiveValue(child.properties?.role, breakpoint, '') ?? '');
|
|
14
27
|
|
|
15
28
|
interface OrderItemLike {
|
|
16
29
|
id?: number;
|
|
@@ -62,10 +75,13 @@ const sortOrders = (orders: OrderLike[], sort: string): OrderLike[] => {
|
|
|
62
75
|
* Properties: limit, orderHrefBase, sort, showDate, showStatus, showAmount,
|
|
63
76
|
* showItemImages, imageWidth, imageHeight, imageGap, imageAlign, maxImages,
|
|
64
77
|
* numberLabel, dateLabel, totalLabel, detailLinkLabel, showTitle, title,
|
|
65
|
-
* showPagination, perPage, emptyLabel,
|
|
66
|
-
* currencyText, currencyPosition.
|
|
78
|
+
* showPagination, perPage, emptyLabel, previewEmptyState, decimals,
|
|
79
|
+
* currencyDisplay, currencyText, currencyPosition.
|
|
67
80
|
* Parts: title, list, item, images, image-overflow, link, date, total,
|
|
68
81
|
* detail-link, status, pagination, empty.
|
|
82
|
+
* A child group with role `order-list-empty` replaces the whole block (title
|
|
83
|
+
* included) once the shopper turns out to have no orders; without one the
|
|
84
|
+
* Empty Message (`emptyLabel`) is shown as before.
|
|
69
85
|
*/
|
|
70
86
|
const OrderListBlock = ({
|
|
71
87
|
block,
|
|
@@ -142,6 +158,22 @@ const OrderListBlock = ({
|
|
|
142
158
|
const emptyLabel = String(
|
|
143
159
|
getResponsiveValue(block.properties?.emptyLabel, currentBreakpoint, '')
|
|
144
160
|
);
|
|
161
|
+
// Editor-only: an author who has orders would otherwise never see the
|
|
162
|
+
// empty-orders layout on the canvas, so there would be nothing to select
|
|
163
|
+
// and arrange. Read behind isDesigner, so it can never empty a live list.
|
|
164
|
+
const previewEmptyState = readToggle(
|
|
165
|
+
block.properties?.previewEmptyState,
|
|
166
|
+
currentBreakpoint,
|
|
167
|
+
false
|
|
168
|
+
);
|
|
169
|
+
// Hidden children stay on the canvas (so they can be brought back) and are
|
|
170
|
+
// gone on the storefront — the same rule the group block applies.
|
|
171
|
+
const emptyStateChild =
|
|
172
|
+
(block.blocks || []).find(
|
|
173
|
+
(child) =>
|
|
174
|
+
readChildRole(child, currentBreakpoint) === EMPTY_STATE_ROLE &&
|
|
175
|
+
(isDesigner || !child.hidden)
|
|
176
|
+
) ?? null;
|
|
145
177
|
// Empty keeps the built-in row exactly as it always rendered — a brand
|
|
146
178
|
// opts into labels and a standalone detail link by setting these.
|
|
147
179
|
const numberLabel = String(
|
|
@@ -304,7 +336,49 @@ const OrderListBlock = ({
|
|
|
304
336
|
</div>
|
|
305
337
|
) : null;
|
|
306
338
|
|
|
307
|
-
|
|
339
|
+
// "The shopper has no orders" is only known once the account query has
|
|
340
|
+
// answered for a signed-in shopper; until then nothing is claimed. A later
|
|
341
|
+
// page that comes back empty is not "no orders" either.
|
|
342
|
+
const hasNoOrders =
|
|
343
|
+
isAuthenticated &&
|
|
344
|
+
!isLoading &&
|
|
345
|
+
data !== undefined &&
|
|
346
|
+
orders.length === 0 &&
|
|
347
|
+
currentPage === 1;
|
|
348
|
+
|
|
349
|
+
// The authored empty-orders layout replaces the whole block, title
|
|
350
|
+
// included — it carries its own heading when the design wants one. On the
|
|
351
|
+
// canvas it also stands in for the "no orders" placeholder box, unless a
|
|
352
|
+
// row layout is there to be designed against its sample rows.
|
|
353
|
+
if (
|
|
354
|
+
emptyStateChild &&
|
|
355
|
+
(isDesigner
|
|
356
|
+
? previewEmptyState || (hasNoOrders && !customRows)
|
|
357
|
+
: hasNoOrders)
|
|
358
|
+
) {
|
|
359
|
+
return (
|
|
360
|
+
<div
|
|
361
|
+
data-order-list=""
|
|
362
|
+
data-order-list-state="empty"
|
|
363
|
+
style={{ width: '100%' }}
|
|
364
|
+
>
|
|
365
|
+
<ThemeBlock
|
|
366
|
+
block={emptyStateChild}
|
|
367
|
+
placeholderId={placeholderId}
|
|
368
|
+
sectionId={sectionId}
|
|
369
|
+
isDesigner={isDesigner}
|
|
370
|
+
isSelected={selectedBlockId === emptyStateChild.id}
|
|
371
|
+
selectedBlockId={selectedBlockId}
|
|
372
|
+
currentBreakpoint={currentBreakpoint}
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// A row layout only stands in for the rows on the canvas (as sample rows);
|
|
379
|
+
// on the storefront it renders nothing for an empty list, so the empty
|
|
380
|
+
// message must not wait on it.
|
|
381
|
+
if (orders.length === 0 && !(isDesigner && customRows)) {
|
|
308
382
|
if (isDesigner) {
|
|
309
383
|
return (
|
|
310
384
|
<>
|