@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-beta.0
3
+ ## 2.0.167
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.167-beta.0
9
- - @akinon/theme-contract@2.0.167-beta.0
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-beta.0",
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-beta.0",
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-beta.0",
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-beta.0",
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, decimals, currencyDisplay,
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
- if (orders.length === 0 && !customRows) {
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
  <>