@akinon/pz-theme 2.0.154-beta.0 → 2.0.154-rc.6

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,51 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.154-beta.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.154-beta.0
9
- - @akinon/theme-contract@2.0.154-beta.0
3
+ ## 2.0.154-rc.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 8e040731: ZERO-5235: let brands hang actions on order detail lines
8
+ - @akinon/next@2.0.154-rc.6
9
+ - @akinon/theme-contract@2.0.154-rc.6
10
+
11
+ ## 2.0.154-rc.5
12
+
13
+ ### Patch Changes
14
+
15
+ - ad5240c4: ZERO-5254: add custom render and theme editor support to pz-rate-review
16
+ - Updated dependencies [219164a3]
17
+ - Updated dependencies [86187b1c2]
18
+ - Updated dependencies [0cf9ea239]
19
+ - Updated dependencies [97d29893]
20
+ - Updated dependencies [324f97d55]
21
+ - Updated dependencies [51ea06888]
22
+ - Updated dependencies
23
+ - Updated dependencies [2fea41430]
24
+ - Updated dependencies [b55acb768]
25
+ - Updated dependencies [760258c1c]
26
+ - Updated dependencies [143be2b9d]
27
+ - Updated dependencies [7889b08fe]
28
+ - Updated dependencies [9f8cd3bc5]
29
+ - Updated dependencies [bfafa3f49]
30
+ - Updated dependencies [57d7eb305]
31
+ - Updated dependencies [d99a6a7d5]
32
+ - Updated dependencies [9db81a714]
33
+ - Updated dependencies [591e345e1]
34
+ - Updated dependencies [4de5303c5]
35
+ - Updated dependencies [95b139dc1]
36
+ - Updated dependencies [1d00f2d06]
37
+ - Updated dependencies [4ac7b2a1e]
38
+ - Updated dependencies [4998a9631]
39
+ - Updated dependencies [804d2bd6c]
40
+ - Updated dependencies [3909d3224]
41
+ - Updated dependencies [6a3d8a631]
42
+ - Updated dependencies [e18836b20]
43
+ - Updated dependencies [b1111d7b1]
44
+ - Updated dependencies [1595d458e]
45
+ - Updated dependencies [dca128112]
46
+ - Updated dependencies [9c778307]
47
+ - @akinon/next@2.0.154-rc.5
48
+ - @akinon/theme-contract@2.0.154-rc.5
10
49
 
11
50
  ## 2.0.153
12
51
 
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.154-beta.0",
4
+ "version": "2.0.154-rc.6",
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.154-beta.0",
11
+ "@akinon/next": "2.0.154-rc.6",
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.154-beta.0",
16
+ "@akinon/theme-contract": "2.0.154-rc.6",
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.154-beta.0",
24
+ "@akinon/next": "2.0.154-rc.6",
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,118 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { ItemTemplateList } from '../blocks/item-template';
4
+ import { useThemeProduct } from '../blocks/product-block-utils';
5
+ import { useThemeRowScope } from '../blocks/row-scope';
6
+ import blockRendererRegistry from '../blocks/block-renderer-registry';
7
+ import { resetCustomCatalog } from '../custom/catalog';
8
+ import { resetCustomCollection } from '../custom/collect';
9
+ import { isCustomThemeType, registerCustomBlocks } from '../custom';
10
+ import type { Block } from '../theme-block';
11
+
12
+ const READER = 'custom:acme:row-reader';
13
+
14
+ function RowReader() {
15
+ const scope = useThemeRowScope();
16
+ const product = useThemeProduct();
17
+
18
+ return (
19
+ <span
20
+ data-testid="reader"
21
+ data-list={scope?.listType ?? ''}
22
+ data-index={scope?.index ?? ''}
23
+ data-number={String(scope?.row.number ?? '')}
24
+ data-product={String(product?.pk ?? '')}
25
+ />
26
+ );
27
+ }
28
+
29
+ const template = (): Block =>
30
+ ({
31
+ id: 'tpl',
32
+ type: 'group',
33
+ label: 'Row',
34
+ order: 0,
35
+ hidden: false,
36
+ value: '',
37
+ properties: { role: { desktop: 'item-template' } },
38
+ styles: {},
39
+ blocks: [
40
+ {
41
+ id: 'tpl-reader',
42
+ type: READER,
43
+ label: 'Reader',
44
+ order: 0,
45
+ hidden: false,
46
+ value: '',
47
+ properties: {},
48
+ styles: {},
49
+ blocks: []
50
+ }
51
+ ]
52
+ } as Block);
53
+
54
+ const list = (type: string): Block =>
55
+ ({
56
+ id: 'list',
57
+ type,
58
+ label: 'List',
59
+ order: 0,
60
+ hidden: false,
61
+ value: '',
62
+ properties: {},
63
+ styles: {},
64
+ blocks: [template()]
65
+ } as Block);
66
+
67
+ const renderRows = (type: string, items: Record<string, unknown>[]) =>
68
+ render(
69
+ <ItemTemplateList
70
+ block={list(type)}
71
+ items={items}
72
+ placeholderId="p1"
73
+ sectionId="s1"
74
+ />
75
+ );
76
+
77
+ beforeAll(() => {
78
+ registerCustomBlocks('acme', { 'row-reader': RowReader });
79
+ });
80
+
81
+ afterAll(() => {
82
+ const map = (
83
+ blockRendererRegistry as unknown as { renderers: Map<string, unknown> }
84
+ ).renderers;
85
+ Array.from(map.keys())
86
+ .filter(isCustomThemeType)
87
+ .forEach((key) => map.delete(key));
88
+ resetCustomCatalog();
89
+ resetCustomCollection();
90
+ });
91
+
92
+ describe('the row scope', () => {
93
+ it('hands every repeated row to the blocks inside it, on any list', () => {
94
+ renderRows('order-list', [{ number: 'ORD-1' }, { number: 'ORD-2' }]);
95
+
96
+ const readers = screen.getAllByTestId('reader');
97
+ expect(readers).toHaveLength(2);
98
+ expect(readers[0]).toHaveAttribute('data-list', 'order-list');
99
+ expect(readers[0]).toHaveAttribute('data-index', '0');
100
+ expect(readers[0]).toHaveAttribute('data-number', 'ORD-1');
101
+ expect(readers[1]).toHaveAttribute('data-number', 'ORD-2');
102
+ expect(readers[0]).toHaveAttribute('data-product', '');
103
+ });
104
+
105
+ it('keeps the product scope on product lists next to the row scope', () => {
106
+ renderRows('product-grid', [{ pk: 7, name: 'Sneaker' }]);
107
+
108
+ const reader = screen.getByTestId('reader');
109
+ expect(reader).toHaveAttribute('data-list', 'product-grid');
110
+ expect(reader).toHaveAttribute('data-product', '7');
111
+ });
112
+
113
+ it('is closed outside a repeated row', () => {
114
+ render(<RowReader />);
115
+
116
+ expect(screen.getByTestId('reader')).toHaveAttribute('data-list', '');
117
+ });
118
+ });
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import {
3
+ registerOrderLineActions,
4
+ renderOrderLineActions
5
+ } from '../order-line-actions';
6
+
7
+ const context = {
8
+ order: { id: 7, number: '20920397829', created_date: '2026-10-01' },
9
+ line: {
10
+ key: '1',
11
+ name: 'Product',
12
+ image: '',
13
+ href: '',
14
+ price: '100',
15
+ total: 100,
16
+ quantity: 1,
17
+ item: { id: 1, status: { value: '550', label: 'Delivered' } }
18
+ },
19
+ isDesigner: false
20
+ };
21
+
22
+ describe('order line actions registry', () => {
23
+ it('renders nothing until a brand registers', () => {
24
+ expect(renderOrderLineActions(context)).toBeNull();
25
+ });
26
+
27
+ it('hands the line and order to the registered renderer', () => {
28
+ const renderer = jest.fn(() => <span>action</span>);
29
+ const unregister = registerOrderLineActions(renderer);
30
+
31
+ expect(renderOrderLineActions(context)).toEqual(<span>action</span>);
32
+ expect(renderer).toHaveBeenCalledWith(context);
33
+
34
+ unregister();
35
+ expect(renderOrderLineActions(context)).toBeNull();
36
+ });
37
+
38
+ it('swallows a throwing renderer so a brand bug cannot break the line', () => {
39
+ const unregister = registerOrderLineActions(() => {
40
+ throw new Error('boom');
41
+ });
42
+
43
+ expect(renderOrderLineActions(context)).toBeNull();
44
+ unregister();
45
+ });
46
+
47
+ it('only forgets the renderer it was asked to forget', () => {
48
+ const first = registerOrderLineActions(() => 'first');
49
+ registerOrderLineActions(() => 'second');
50
+
51
+ first();
52
+ expect(renderOrderLineActions(context)).toBe('second');
53
+ });
54
+ });
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import OrderListBlock from '../order-list-block';
4
+
5
+ const captured: { items: Record<string, unknown>[] } = { items: [] };
6
+
7
+ jest.mock('../item-template', () => ({
8
+ __esModule: true,
9
+ hasItemTemplate: () => true,
10
+ ItemTemplateList: ({ items }: { items: Record<string, unknown>[] }) => {
11
+ captured.items = items;
12
+ return <div data-testid="item-template-list" />;
13
+ }
14
+ }));
15
+
16
+ jest.mock('next-auth/react', () => ({
17
+ useSession: () => ({ data: null, status: 'authenticated' })
18
+ }));
19
+
20
+ jest.mock('@akinon/next/hooks', () => ({
21
+ __esModule: true,
22
+ useLocalization: () => ({ locale: 'tr', currency: 'try' })
23
+ }));
24
+
25
+ jest.mock('@akinon/next/redux/hooks', () => ({
26
+ __esModule: true,
27
+ useAppSelector: () => undefined,
28
+ useAppDispatch: () => jest.fn()
29
+ }));
30
+
31
+ jest.mock('@akinon/next/data/client/account', () => ({
32
+ __esModule: true,
33
+ useGetOrdersQuery: () => ({
34
+ data: {
35
+ count: 1,
36
+ results: [
37
+ {
38
+ id: 42,
39
+ number: 'ORD-42',
40
+ amount: '100.00',
41
+ created_date: '2026-09-01T10:00:00Z',
42
+ status: { value: '550', label: 'Delivered' },
43
+ orderitem_set: [
44
+ {
45
+ id: 7,
46
+ status: { value: 550, label: 'Delivered' },
47
+ product: {
48
+ sku: 'SKU-1',
49
+ base_code: 'BASE-1',
50
+ name: 'Sneaker',
51
+ image: '/s.jpg'
52
+ }
53
+ },
54
+ {
55
+ status: { value: '400', label: 'Approved' },
56
+ product: { name: 'Boot' }
57
+ }
58
+ ]
59
+ }
60
+ ]
61
+ },
62
+ isLoading: false
63
+ })
64
+ }));
65
+
66
+ describe('order list rows', () => {
67
+ it('publishes the order items with their product codes and status code', () => {
68
+ render(
69
+ <OrderListBlock
70
+ block={{
71
+ id: 'order-list-1',
72
+ type: 'order-list',
73
+ properties: {},
74
+ styles: {},
75
+ blocks: []
76
+ }}
77
+ placeholderId="p1"
78
+ sectionId="s1"
79
+ />
80
+ );
81
+
82
+ expect(captured.items).toHaveLength(1);
83
+ expect(captured.items[0]).toMatchObject({
84
+ id: 42,
85
+ number: 'ORD-42',
86
+ createdDate: '2026-09-01T10:00:00Z'
87
+ });
88
+ expect(captured.items[0].items).toEqual([
89
+ {
90
+ id: 7,
91
+ name: 'Sneaker',
92
+ image: '/s.jpg',
93
+ status: 'Delivered',
94
+ statusValue: '550',
95
+ product: {
96
+ sku: 'SKU-1',
97
+ base_code: 'BASE-1',
98
+ name: 'Sneaker',
99
+ image: '/s.jpg'
100
+ }
101
+ },
102
+ {
103
+ id: 1,
104
+ name: 'Boot',
105
+ image: '',
106
+ status: 'Approved',
107
+ statusValue: '400',
108
+ product: { sku: '', base_code: '', name: 'Boot', image: '' }
109
+ }
110
+ ]);
111
+ });
112
+ });
@@ -15,6 +15,7 @@ import {
15
15
  resolveItemProduct
16
16
  } from './item-template-utils';
17
17
  import { ThemeItemScopeProvider } from './product-block-utils';
18
+ import { ThemeRowScopeProvider } from './row-scope';
18
19
 
19
20
  export {
20
21
  findItemTemplate,
@@ -120,20 +121,15 @@ export const ItemTemplateList = ({
120
121
  const scopesProducts = publishesProductItems(block.type);
121
122
  // The same slice the iterator applied, so clone N is handed the row it
122
123
  // was built from rather than row N.
123
- const scopedRows = scopesProducts
124
- ? sliceIteratorItems(block, rows).items
125
- : rows;
124
+ const slicedRows = sliceIteratorItems(block, rows).items;
126
125
 
127
126
  return (
128
127
  <>
129
128
  {clones.map((clone, index) => {
130
129
  const key = clone.id || `${template.id}-item-${index}`;
130
+ const rowData = slicedRows[index] ?? {};
131
131
  const row = (
132
132
  <ThemeBlock
133
- // Keyed here so the non-scoped branch is the element it always
134
- // was: wrapping it in anything, even a fragment, would change the
135
- // element type at this position and remount every existing row.
136
- key={key}
137
133
  block={clone}
138
134
  placeholderId={placeholderId}
139
135
  sectionId={sectionId}
@@ -143,22 +139,32 @@ export const ItemTemplateList = ({
143
139
  currentBreakpoint={currentBreakpoint}
144
140
  />
145
141
  );
142
+ const scoped = scopesProducts
143
+ ? resolveItemProduct(block.type, rowData)
144
+ : null;
146
145
 
147
- if (!scopesProducts) return row;
148
-
149
- const scoped = resolveItemProduct(block.type, scopedRows[index]);
150
-
151
- // Context only — the provider renders no element, so the markup a
152
- // published row produces is unchanged.
146
+ // Context only — the providers render no element, so the markup a
147
+ // published row produces is unchanged. Every list opens the row
148
+ // scope; only product lists open the product scope on top of it.
153
149
  return (
154
- <ThemeItemScopeProvider
150
+ <ThemeRowScopeProvider
155
151
  key={key}
156
- product={
157
- scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
158
- }
152
+ listType={block.type}
153
+ row={rowData}
154
+ index={index}
159
155
  >
160
- {row}
161
- </ThemeItemScopeProvider>
156
+ {scopesProducts ? (
157
+ <ThemeItemScopeProvider
158
+ product={
159
+ scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
160
+ }
161
+ >
162
+ {row}
163
+ </ThemeItemScopeProvider>
164
+ ) : (
165
+ row
166
+ )}
167
+ </ThemeRowScopeProvider>
162
168
  );
163
169
  })}
164
170
  </>
@@ -9,6 +9,7 @@ import { getResponsiveValue, partAttrs } from '../utils';
9
9
  import { formatDate, formatMoney } from './commerce-format';
10
10
  import { readMoneyStyle } from './money-style';
11
11
  import { hasItemTemplate, ItemTemplateList } from './item-template';
12
+ import { renderOrderLineActions } from './order-line-actions';
12
13
  import { BlockRendererProps } from './block-renderer-registry';
13
14
  import {
14
15
  DesignerPlaceholder,
@@ -16,7 +17,7 @@ import {
16
17
  readToggle
17
18
  } from './product-block-utils';
18
19
 
19
- interface OrderItemLike {
20
+ type OrderItemLike = {
20
21
  id?: number;
21
22
  price?: string;
22
23
  quantity?: string | number;
@@ -34,7 +35,7 @@ interface OrderItemLike {
34
35
  absolute_url?: string;
35
36
  attributes?: Record<string, unknown>;
36
37
  } | null;
37
- }
38
+ };
38
39
 
39
40
  interface AddressLike {
40
41
  first_name?: string;
@@ -272,7 +273,9 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
272
273
  * order key is read from the URL; the editor owns which sections are exposed,
273
274
  * every caption and the styling.
274
275
  * A designer can mark one child as the row layout for the order lines; without
275
- * one the built-in line renders.
276
+ * one the built-in line renders. A brand may hang per-line actions on the
277
+ * built-in line through `registerOrderLineActions`; they render inside the
278
+ * line's `item-actions` part.
276
279
  * Properties: numberLabel, numberTag, dateLabel, headerTotalLabel,
277
280
  * showInvoiceLink, invoiceLinkLabel, showCancelLink, cancelLinkLabel,
278
281
  * cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
@@ -288,7 +291,7 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
288
291
  * currencyText, currencyPosition.
289
292
  * Parts: detail, header, number, date, header-total, status,
290
293
  * invoice-link, cancel-link, list, item, image, name, quantity, color, size,
291
- * item-status, item-tracking, item-return-code, price, shipment,
294
+ * item-status, item-tracking, item-return-code, item-actions, price, shipment,
292
295
  * delivery, tracking, addresses, address, address-label, totals, total-row,
293
296
  * total-label, total-value, grand-total, payment-info, payment-info-label.
294
297
  */
@@ -758,6 +761,14 @@ const OrderDetailBlock = ({
758
761
  const returnCodeValue = showReturnCode
759
762
  ? resolveReturnCode(item.active_cancellation_request)
760
763
  : '';
764
+ const lineActions = renderOrderLineActions({
765
+ order,
766
+ line,
767
+ isDesigner
768
+ });
769
+ const showLineMeta =
770
+ showShipmentStatus &&
771
+ Boolean(item.status?.label || trackingValue || returnCodeValue);
761
772
 
762
773
  return (
763
774
  <li
@@ -873,63 +884,68 @@ const OrderDetailBlock = ({
873
884
  </div>
874
885
  </div>
875
886
 
876
- {showShipmentStatus &&
877
- (item.status?.label ||
878
- trackingValue ||
879
- returnCodeValue) && (
880
- <div
881
- style={{
882
- display: 'flex',
883
- flexDirection: 'column',
884
- alignItems: isMobile ? 'flex-start' : 'flex-end',
885
- textAlign: isMobile ? 'left' : 'right',
886
- gap: '4px',
887
- flex: isMobile ? '1 1 100%' : '0 0 auto'
888
- }}
889
- >
890
- {item.status?.label && (
891
- <span
892
- data-order-detail-item-status=""
893
- data-order-status-value={item.status.value}
894
- {...partAttrs('item-status')}
895
- style={{ display: 'inline-block' }}
896
- >
897
- {item.status.label}
898
- </span>
899
- )}
900
- {trackingValue && (
901
- <span
902
- data-order-detail-item-tracking=""
903
- {...partAttrs('item-tracking')}
904
- style={{ opacity: 0.8 }}
905
- >
906
- {lineTrackingLabel}{' '}
907
- {item.tracking_url ? (
908
- <a
909
- href={isDesigner ? '#' : item.tracking_url}
910
- onClick={guard}
911
- target={isDesigner ? undefined : '_blank'}
912
- rel="noreferrer"
913
- style={{ color: 'inherit' }}
914
- >
915
- {item.tracking_number || item.tracking_url}
916
- </a>
917
- ) : (
918
- trackingValue
919
- )}
920
- </span>
921
- )}
922
- {returnCodeValue && (
923
- <span
924
- data-order-detail-item-return-code=""
925
- {...partAttrs('item-return-code')}
926
- style={{ opacity: 0.8 }}
927
- >
928
- {returnCodeLabel} {returnCodeValue}
929
- </span>
930
- )}
931
- </div>
932
- )}
887
+ {(showLineMeta || lineActions) && (
888
+ <div
889
+ style={{
890
+ display: 'flex',
891
+ flexDirection: 'column',
892
+ alignItems: isMobile ? 'flex-start' : 'flex-end',
893
+ textAlign: isMobile ? 'left' : 'right',
894
+ gap: '4px',
895
+ flex: isMobile ? '1 1 100%' : '0 0 auto'
896
+ }}
897
+ >
898
+ {lineActions && (
899
+ <div
900
+ data-order-detail-item-actions=""
901
+ {...partAttrs('item-actions')}
902
+ >
903
+ {lineActions}
904
+ </div>
905
+ )}
906
+ {showLineMeta && item.status?.label && (
907
+ <span
908
+ data-order-detail-item-status=""
909
+ data-order-status-value={item.status.value}
910
+ {...partAttrs('item-status')}
911
+ style={{ display: 'inline-block' }}
912
+ >
913
+ {item.status.label}
914
+ </span>
915
+ )}
916
+ {showLineMeta && trackingValue && (
917
+ <span
918
+ data-order-detail-item-tracking=""
919
+ {...partAttrs('item-tracking')}
920
+ style={{ opacity: 0.8 }}
921
+ >
922
+ {lineTrackingLabel}{' '}
923
+ {item.tracking_url ? (
924
+ <a
925
+ href={isDesigner ? '#' : item.tracking_url}
926
+ onClick={guard}
927
+ target={isDesigner ? undefined : '_blank'}
928
+ rel="noreferrer"
929
+ style={{ color: 'inherit' }}
930
+ >
931
+ {item.tracking_number || item.tracking_url}
932
+ </a>
933
+ ) : (
934
+ trackingValue
935
+ )}
936
+ </span>
937
+ )}
938
+ {showLineMeta && returnCodeValue && (
939
+ <span
940
+ data-order-detail-item-return-code=""
941
+ {...partAttrs('item-return-code')}
942
+ style={{ opacity: 0.8 }}
943
+ >
944
+ {returnCodeLabel} {returnCodeValue}
945
+ </span>
946
+ )}
947
+ </div>
948
+ )}
933
949
  </div>
934
950
  </li>
935
951
  );
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { GroupedOrderLine, OrderLineLike } from './product-block-utils';
3
+
4
+ export interface OrderLineActionsOrder {
5
+ id?: number;
6
+ number?: string;
7
+ created_date?: string;
8
+ }
9
+
10
+ export type OrderLineActionsItem = OrderLineLike & {
11
+ status?: { value?: string; label?: string };
12
+ [key: string]: unknown;
13
+ };
14
+
15
+ export interface OrderLineActionsContext {
16
+ order: OrderLineActionsOrder;
17
+ line: GroupedOrderLine<OrderLineActionsItem>;
18
+ isDesigner: boolean;
19
+ }
20
+
21
+ export type OrderLineActionsRenderer = (
22
+ context: OrderLineActionsContext
23
+ ) => ReactNode;
24
+
25
+ let renderer: OrderLineActionsRenderer | null = null;
26
+
27
+ export const registerOrderLineActions = (
28
+ fn: OrderLineActionsRenderer
29
+ ): (() => void) => {
30
+ renderer = fn;
31
+ return () => {
32
+ if (renderer === fn) renderer = null;
33
+ };
34
+ };
35
+
36
+ export const renderOrderLineActions = (
37
+ context: OrderLineActionsContext
38
+ ): ReactNode => {
39
+ if (!renderer) return null;
40
+ try {
41
+ return renderer(context) ?? null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ };
@@ -13,7 +13,14 @@ import { readToggle } from './product-block-utils';
13
13
  import { BlockRendererProps } from './block-renderer-registry';
14
14
 
15
15
  interface OrderItemLike {
16
- product?: { image?: string; name?: string } | null;
16
+ id?: number;
17
+ status?: { value?: string | number; label?: string };
18
+ product?: {
19
+ image?: string;
20
+ name?: string;
21
+ sku?: string;
22
+ base_code?: string;
23
+ } | null;
17
24
  }
18
25
 
19
26
  interface OrderLike {
@@ -248,7 +255,23 @@ const OrderListBlock = ({
248
255
  maxImages
249
256
  ),
250
257
  url: hrefFor(order),
251
- href: hrefFor(order)
258
+ href: hrefFor(order),
259
+ createdDate: order.created_date || '',
260
+ items: (Array.isArray(order.orderitem_set) ? order.orderitem_set : []).map(
261
+ (item, itemIndex) => ({
262
+ id: item?.id ?? itemIndex,
263
+ name: item?.product?.name || '',
264
+ image: item?.product?.image || '',
265
+ status: item?.status?.label || '',
266
+ statusValue: String(item?.status?.value ?? ''),
267
+ product: {
268
+ sku: item?.product?.sku || '',
269
+ base_code: item?.product?.base_code || '',
270
+ name: item?.product?.name || '',
271
+ image: item?.product?.image || ''
272
+ }
273
+ })
274
+ )
252
275
  }));
253
276
 
254
277
  // A designer who marked a child as the row layout gets that layout repeated
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ export interface ThemeRowScopeValue {
6
+ listType: string;
7
+ row: Record<string, unknown>;
8
+ index: number;
9
+ }
10
+
11
+ const ThemeRowScopeContext = React.createContext<ThemeRowScopeValue | null>(
12
+ null
13
+ );
14
+
15
+ export const ThemeRowScopeProvider = ({
16
+ listType,
17
+ row,
18
+ index,
19
+ children
20
+ }: ThemeRowScopeValue & { children: React.ReactNode }) => {
21
+ const value = React.useMemo(
22
+ () => ({ listType, row, index }),
23
+ [listType, row, index]
24
+ );
25
+
26
+ return (
27
+ <ThemeRowScopeContext.Provider value={value}>
28
+ {children}
29
+ </ThemeRowScopeContext.Provider>
30
+ );
31
+ };
32
+
33
+ /** Non-null while a repeated row of any list renders; null everywhere else. */
34
+ export const useThemeRowScope = (): ThemeRowScopeValue | null =>
35
+ React.useContext(ThemeRowScopeContext);
@@ -41,7 +41,7 @@ const orderPayload = {
41
41
  orderitem_set: [
42
42
  {
43
43
  price: '649.95',
44
- status: { label: 'Shipped' },
44
+ status: { value: '500', label: 'Shipped' },
45
45
  product: {
46
46
  name: 'Spring Sneaker',
47
47
  image: '/i.jpg',
@@ -63,6 +63,19 @@ describe('buildOrderPageContext', () => {
63
63
  expect(context.order.currency).toBe('TRY');
64
64
  expect(context.order.items).toHaveLength(1);
65
65
  expect(context.order.items[0].title).toBe('Spring Sneaker');
66
+ expect(context.order.items[0].status).toBe('Shipped');
67
+ expect(context.order.items[0].statusValue).toBe('500');
68
+ });
69
+
70
+ it('stringifies a numeric order item status code', () => {
71
+ const context = buildOrderPageContext({
72
+ order: {
73
+ ...orderPayload,
74
+ orderitem_set: [{ status: { value: 550, label: 'Delivered' } }]
75
+ }
76
+ });
77
+
78
+ expect(context.order.items[0].statusValue).toBe('550');
66
79
  });
67
80
 
68
81
  it('does not publish the identity fields the raw payload carries', () => {
@@ -59,6 +59,9 @@ const toRecord = (value: unknown): UnknownRecord =>
59
59
  const toStringValue = (value: unknown, fallback = ''): string =>
60
60
  typeof value === 'string' ? value : fallback;
61
61
 
62
+ const toCodeValue = (value: unknown): string =>
63
+ typeof value === 'number' ? String(value) : toStringValue(value);
64
+
62
65
  const humanizeSlug = (value: string) =>
63
66
  value
64
67
  .split('-')
@@ -479,6 +482,7 @@ export const buildOrderPageContext = ({
479
482
  unitPrice: toStringValue(item.price),
480
483
  quantity: 1,
481
484
  status: toStringValue(toRecord(item.status).label),
485
+ statusValue: toCodeValue(toRecord(item.status).value),
482
486
  currency
483
487
  };
484
488
  });
@@ -303,6 +303,26 @@ attached". The editor's **Use preview** toggle turns it off while the editor
303
303
  stays open, so the canvas behaves like the live site: whatever you held open
304
304
  closes and links navigate again.
305
305
 
306
+ ## Reading the row a block is placed in
307
+
308
+ A block dropped into a list's row layout (the child a designer marked as the
309
+ row template of an order list, a product grid, an address list, …) renders once
310
+ per row. The product lists open the product scope `useThemeProduct()` reads;
311
+ every list also opens a row scope that carries the row itself:
312
+
313
+ ```ts
314
+ import { useThemeRowScope } from '@akinon/pz-theme/src/blocks/row-scope';
315
+
316
+ const scope = useThemeRowScope();
317
+ ```
318
+
319
+ Inside a row it returns `{ listType, row, index }`, for example
320
+ `listType: 'order-list'` with the order's `number` and `items` on `row`; anywhere
321
+ else it returns `null`. `row` is the same record the row's bindings resolve
322
+ against, so whatever a text block can bind to (`item.number`), code can read.
323
+ Import it from that deep path for the same reason as `useThemePageContext`: the
324
+ package root is not importable from a client module.
325
+
306
326
  ## Reporting what happened
307
327
 
308
328
  A custom unit can report a moment — a strip was seen, a lookbook was opened, a