@akinon/pz-theme 2.0.166-rc.0 → 2.0.166-rc.2

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,5 +1,22 @@
1
1
  # @akinon/pz-theme
2
2
 
3
+ ## 2.0.166-rc.2
4
+
5
+ ### Patch Changes
6
+
7
+ - f6871947: ZERO-5509: The order-detail block can hand its whole page to a child group with role `order-detail-layout`. The group is drawn once over the order in place of the built-in markup: its blocks bind `item.*` to the order (number, date, status, totals, discounts, installments, invoice / cancel / tracking links, addresses, payment), already formatted with the block's money style, and a repeat over `item.lines` draws the line rows. Line rows (also in the existing row layout) now carry color, size, tracking, carrier, return code / method / carrier / pickup date, and are scoped as a one-line order so the rate & review action placed in a line rates that line only. Clones of a repeat inside a row now open a row scope for their entry (`Block.itemScopeRow`). Without a layout the block renders exactly as before.
8
+ - @akinon/next@2.0.166-rc.2
9
+ - @akinon/theme-contract@2.0.166-rc.2
10
+
11
+ ## 2.0.166-rc.1
12
+
13
+ ### Patch Changes
14
+
15
+ - e3c2bab2: ZERO-5521: restore the text block's useValueLocale import and the /api/auth no-store header rule that the automatic RC sync dropped
16
+ - Updated dependencies [e3c2bab2]
17
+ - @akinon/next@2.0.166-rc.1
18
+ - @akinon/theme-contract@2.0.166-rc.1
19
+
3
20
  ## 2.0.166-rc.0
4
21
 
5
22
  ### Patch Changes
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.166-rc.0",
4
+ "version": "2.0.166-rc.2",
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.166-rc.0",
11
+ "@akinon/next": "2.0.166-rc.2",
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.166-rc.0",
16
+ "@akinon/theme-contract": "2.0.166-rc.2",
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.166-rc.0",
24
+ "@akinon/next": "2.0.166-rc.2",
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,311 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import OrderDetailBlock from '../order-detail-block';
4
+ import blockRendererRegistry from '../block-renderer-registry';
5
+ import { useThemeRowScope } from '../row-scope';
6
+
7
+ /**
8
+ * ZERO-5509 — the order detail page as blocks. A child group with role
9
+ * `order-detail-layout` is drawn once over the order instead of the built-in
10
+ * markup: its blocks bind `item.*` to the order, a repeat over `item.lines`
11
+ * draws a row per line, and each line row is a row of its own, so a block that
12
+ * acts on "its row" (the rate & review action) answers for that line only.
13
+ */
14
+ let order: Record<string, unknown> | null = null;
15
+ let isLoading = false;
16
+
17
+ jest.mock('next/navigation', () => ({
18
+ useParams: () => ({ id: '42' }),
19
+ useSearchParams: () => null
20
+ }));
21
+
22
+ jest.mock('next-auth/react', () => ({
23
+ useSession: () => ({ data: null, status: 'authenticated' })
24
+ }));
25
+
26
+ jest.mock('@akinon/next/hooks', () => ({
27
+ __esModule: true,
28
+ useLocalization: () => ({ locale: 'tr', currency: 'try' })
29
+ }));
30
+
31
+ jest.mock('@akinon/next/data/client/account', () => ({
32
+ __esModule: true,
33
+ useGetOrderQuery: () => ({ data: order, isLoading })
34
+ }));
35
+
36
+ // Stands in for the rate & review action: shows which row it was handed.
37
+ const RowProbe = () => {
38
+ const scope = useThemeRowScope();
39
+ const row = (scope?.row ?? {}) as {
40
+ number?: string;
41
+ items?: Array<{ product?: { sku?: string } }>;
42
+ };
43
+ return (
44
+ <span data-testid="row-probe">
45
+ {`${row.number}:${(row.items ?? [])
46
+ .map((item) => item.product?.sku)
47
+ .join(',')}`}
48
+ </span>
49
+ );
50
+ };
51
+ blockRendererRegistry.register('test-row-probe', RowProbe as never);
52
+
53
+ const line = (id: number, sku: string, color: string, size: string) => ({
54
+ id,
55
+ price: '5499.00',
56
+ quantity: 1,
57
+ status: { value: '550', label: 'Teslim edildi' },
58
+ tracking_number: `TRK${id}`,
59
+ tracking_url: `https://carrier.example/${id}`,
60
+ product: {
61
+ pk: id,
62
+ sku,
63
+ base_code: `BASE-${id}`,
64
+ name: `Gömlek ${id}`,
65
+ image: `/img-${id}.jpg`,
66
+ absolute_url: `/p-${id}/`,
67
+ attributes: { integration_renk_detay: color, filtrenebilir_beden: size }
68
+ }
69
+ });
70
+
71
+ const ORDER = {
72
+ id: 42,
73
+ number: '20920397829',
74
+ created_date: '2026-06-10T10:00:00Z',
75
+ amount: '15499.00',
76
+ amount_without_discount: '15499.00',
77
+ discount_amount: '2499.00',
78
+ shipping_amount: '0.00',
79
+ status: { value: '400', label: 'Onaylandı' },
80
+ currency: { value: 'try', label: 'TRY' },
81
+ e_archive_url: 'https://invoice.example/42',
82
+ tracking_number: 'TRK1',
83
+ tracking_url: 'https://carrier.example/1',
84
+ installment_count: 3,
85
+ orderitem_set: [
86
+ line(1, 'SKU-1', 'Beyaz', 'S'),
87
+ line(2, 'SKU-2', 'Lacivert', 'M')
88
+ ],
89
+ discountitem_set: [{ name: 'Sepet indirimi', amount: '2499.00' }],
90
+ shipping_address: {
91
+ first_name: 'İlker',
92
+ last_name: 'Gökçil',
93
+ line: 'Atatürk Cad. No:2/5',
94
+ district: { name: 'Atatürk Mah.' },
95
+ city: { name: 'İstanbul' }
96
+ },
97
+ billing_address: null,
98
+ bank: { name: 'İş Bankası' },
99
+ payment_option: { name: 'Kredi Kartı' }
100
+ };
101
+
102
+ const text = (id: string, binding: string, extra = {}) => ({
103
+ id,
104
+ type: 'text',
105
+ label: id,
106
+ value: '',
107
+ properties: { tag: 'span', dataBinding: binding, ...extra },
108
+ styles: {},
109
+ blocks: []
110
+ });
111
+
112
+ const layout = (overrides: Record<string, unknown> = {}) => ({
113
+ id: 'order-layout',
114
+ type: 'group',
115
+ label: 'Order Layout',
116
+ properties: { tag: 'div', role: 'order-detail-layout' },
117
+ styles: {},
118
+ blocks: [
119
+ text('number', 'item.number'),
120
+ text('date', 'item.date'),
121
+ text('total', 'item.total'),
122
+ {
123
+ ...text('shipping-free', '', {
124
+ visibleWhenField: 'item.isFreeShipping',
125
+ visibleWhenMode: 'truthy'
126
+ }),
127
+ value: 'Ücretsiz'
128
+ },
129
+ {
130
+ ...text('shipping-paid', 'item.shipping', {
131
+ visibleWhenField: 'item.isFreeShipping',
132
+ visibleWhenMode: 'falsy'
133
+ })
134
+ },
135
+ text(
136
+ 'installments',
137
+ '{{item.installmentCount}} x {{item.installmentAmount}}'
138
+ ),
139
+ text('address', 'item.shippingAddress'),
140
+ text('billing', 'item.billingAddress', {
141
+ visibleWhenField: 'item.hasBillingAddress',
142
+ visibleWhenMode: 'truthy'
143
+ }),
144
+ {
145
+ id: 'discounts',
146
+ type: 'group',
147
+ label: 'Discounts',
148
+ isIterator: true,
149
+ iteratorDataPath: 'item.discounts',
150
+ properties: { tag: 'div' },
151
+ styles: {},
152
+ blocks: [text('discount', '{{item.name}} {{item.amount}}')]
153
+ },
154
+ {
155
+ id: 'lines',
156
+ type: 'group',
157
+ label: 'Lines',
158
+ isIterator: true,
159
+ iteratorDataPath: 'item.lines',
160
+ properties: { tag: 'div' },
161
+ styles: {},
162
+ blocks: [
163
+ {
164
+ id: 'line',
165
+ type: 'group',
166
+ label: 'Line',
167
+ properties: { tag: 'div' },
168
+ styles: {},
169
+ blocks: [
170
+ text('line-name', 'item.name'),
171
+ text('line-color', 'Renk: {{item.color}}'),
172
+ text('line-size', 'Beden: {{item.size}}'),
173
+ text('line-tracking', 'item.trackingNumber'),
174
+ {
175
+ id: 'probe',
176
+ type: 'test-row-probe',
177
+ label: 'Probe',
178
+ properties: {},
179
+ styles: {},
180
+ blocks: []
181
+ }
182
+ ]
183
+ }
184
+ ]
185
+ }
186
+ ],
187
+ ...overrides
188
+ });
189
+
190
+ const block = (blocks: unknown[] = [layout()]) =>
191
+ ({
192
+ id: 'order-detail-1',
193
+ type: 'order-detail',
194
+ properties: {
195
+ colorAttributeKey: { desktop: 'integration_renk_detay' },
196
+ sizeAttributeKey: { desktop: 'filtrenebilir_beden' },
197
+ currencyDisplay: { desktop: 'custom' },
198
+ currencyText: { desktop: 'TL' },
199
+ currencyPosition: { desktop: 'after' },
200
+ decimals: { desktop: 'auto' }
201
+ },
202
+ styles: {},
203
+ blocks
204
+ } as never);
205
+
206
+ const renderBlock = (blocks?: unknown[], isDesigner = false) =>
207
+ render(
208
+ <OrderDetailBlock
209
+ block={block(blocks)}
210
+ placeholderId="p1"
211
+ sectionId="s1"
212
+ isDesigner={isDesigner}
213
+ />
214
+ );
215
+
216
+ beforeEach(() => {
217
+ order = ORDER;
218
+ isLoading = false;
219
+ });
220
+
221
+ describe('order detail layout', () => {
222
+ it('draws the authored layout in place of the built-in markup', () => {
223
+ const { container } = renderBlock();
224
+
225
+ expect(
226
+ container.querySelector('[data-order-detail-layout]')
227
+ ).not.toBeNull();
228
+ expect(container.querySelector('[data-order-detail-header]')).toBeNull();
229
+ expect(container.querySelector('[data-order-detail-items]')).toBeNull();
230
+ });
231
+
232
+ it('binds the order-level values, formatted the way the block formats money', () => {
233
+ const { container } = renderBlock();
234
+ const textOf = (selector: string) =>
235
+ container.querySelector(`[data-block-id^="${selector}"]`)?.textContent;
236
+
237
+ expect(textOf('number')).toBe('20920397829');
238
+ expect(textOf('date')).toBe('10.06.2026');
239
+ expect(textOf('total')).toMatch(/^15\.499(,00)? TL$/);
240
+ expect(textOf('installments')).toMatch(/^3 x 5\.166,33 TL$/);
241
+ expect(textOf('address')).toBe(
242
+ 'Atatürk Cad. No:2/5 Atatürk Mah. / İstanbul'
243
+ );
244
+ expect(textOf('discount')).toMatch(/^Sepet indirimi -2\.499(,00)? TL$/);
245
+ });
246
+
247
+ it('shows and hides per the row conditions on the storefront', () => {
248
+ const { container } = renderBlock();
249
+
250
+ expect(container.textContent).toContain('Ücretsiz');
251
+ expect(
252
+ container.querySelector('[data-block-id^="shipping-paid"]')
253
+ ).toBeNull();
254
+ // No billing address on this order: the bound block is hidden.
255
+ expect(container.querySelector('[data-block-id^="billing"]')).toBeNull();
256
+ });
257
+
258
+ it('repeats a row per line with its color, size and tracking', () => {
259
+ const { container } = renderBlock();
260
+ const lines = container.querySelectorAll('[data-block-id^="line-color"]');
261
+
262
+ expect(lines).toHaveLength(2);
263
+ expect(lines[0].textContent).toBe('Renk: Beyaz');
264
+ expect(lines[1].textContent).toBe('Renk: Lacivert');
265
+ expect(
266
+ container.querySelectorAll('[data-block-id^="line-size"]')[1].textContent
267
+ ).toBe('Beden: M');
268
+ expect(
269
+ container.querySelectorAll('[data-block-id^="line-tracking"]')[0]
270
+ .textContent
271
+ ).toBe('TRK1');
272
+ });
273
+
274
+ it('gives every line row its own row scope, as a one-line order', () => {
275
+ renderBlock();
276
+
277
+ expect(
278
+ screen.getAllByTestId('row-probe').map((el) => el.textContent)
279
+ ).toEqual(['20920397829:SKU-1', '20920397829:SKU-2']);
280
+ });
281
+
282
+ it('leaves a hidden layout off the storefront and draws the built-in order', () => {
283
+ const { container } = renderBlock([layout({ hidden: true })]);
284
+
285
+ expect(container.querySelector('[data-order-detail-layout]')).toBeNull();
286
+ expect(
287
+ container.querySelector('[data-order-detail-header]')
288
+ ).not.toBeNull();
289
+ });
290
+
291
+ it('keeps drawing the built-in order when there is no layout', () => {
292
+ const { container } = renderBlock([]);
293
+
294
+ expect(container.querySelector('[data-order-detail-layout]')).toBeNull();
295
+ expect(
296
+ container.querySelector('[data-order-detail-header]')
297
+ ).not.toBeNull();
298
+ });
299
+
300
+ it('draws the layout over the sample order on the canvas', () => {
301
+ order = null;
302
+ const { container } = renderBlock(undefined, true);
303
+
304
+ expect(
305
+ container.querySelector('[data-order-detail-layout]')
306
+ ).not.toBeNull();
307
+ expect(
308
+ container.querySelectorAll('[data-block-id^="line-color"]').length
309
+ ).toBeGreaterThan(0);
310
+ });
311
+ });
@@ -8,9 +8,14 @@ import { useLocalization } from '@akinon/next/hooks';
8
8
  import { getResponsiveValue, partAttrs } from '../utils';
9
9
  import { formatDate, formatMoney } from './commerce-format';
10
10
  import { readMoneyStyle } from './money-style';
11
- import { hasItemTemplate, ItemTemplateList } from './item-template';
11
+ import {
12
+ hasItemTemplate,
13
+ ITEM_TEMPLATE_ROLE,
14
+ ItemTemplateList
15
+ } from './item-template';
12
16
  import { renderOrderLineActions } from './order-line-actions';
13
17
  import { BlockRendererProps } from './block-renderer-registry';
18
+ import type { Block } from '../theme-block';
14
19
  import {
15
20
  DesignerPlaceholder,
16
21
  groupOrderItems,
@@ -33,12 +38,15 @@ type OrderItemLike = {
33
38
  status?: { value?: string; label?: string };
34
39
  tracking_url?: string;
35
40
  tracking_number?: string;
41
+ shipping_company?: { value?: string; label?: string } | null;
36
42
  estimated_delivery_date?: string;
37
43
  price_currency?: { value?: string; label?: string };
38
44
  is_refundable?: boolean;
39
45
  active_cancellation_request?: unknown;
40
46
  product?: {
41
47
  pk?: number;
48
+ sku?: string;
49
+ base_code?: string;
42
50
  name?: string;
43
51
  image?: string;
44
52
  absolute_url?: string;
@@ -70,6 +78,7 @@ interface OrderLike {
70
78
  shipping_amount?: string;
71
79
  tracking_url?: string;
72
80
  tracking_number?: string;
81
+ shipping_company?: { value?: string; label?: string } | null;
73
82
  status?: { value?: string; label?: string };
74
83
  currency?: { value?: string; label?: string };
75
84
  orderitem_set?: OrderItemLike[];
@@ -108,6 +117,19 @@ const ORDER_PARAM_KEYS = [
108
117
  // opts into h1/h2/h3 when the order number is the page's main heading.
109
118
  const NUMBER_TAGS = ['span', 'div', 'h1', 'h2', 'h3'];
110
119
 
120
+ /**
121
+ * A child group marked with this role is the whole order page, drawn in place
122
+ * of everything the block renders itself (ZERO-5509). It is repeated once, over
123
+ * the order: its blocks bind `item.*` to the order (number, date, totals,
124
+ * addresses, payment), and a repeat inside it over `item.lines` draws one row
125
+ * per order line, so the header, the line rows, the totals and every caption
126
+ * are blocks the panel arranges instead of fixed markup styled with CSS.
127
+ */
128
+ export const ORDER_LAYOUT_ROLE = 'order-detail-layout';
129
+
130
+ const readChildRole = (child: Block, breakpoint: string): string =>
131
+ String(getResponsiveValue(child.properties?.role, breakpoint, '') ?? '');
132
+
111
133
  const firstString = (
112
134
  read: (key: string) => unknown,
113
135
  keys: string[]
@@ -673,8 +695,16 @@ const OrderDetailBlock = ({
673
695
  ? {}
674
696
  : { width: '50%', marginLeft: '50%' };
675
697
 
698
+ // A row publishes every field the built-in line can show, whatever its own
699
+ // show/hide toggles say: those govern the built-in line, and a designer's
700
+ // own layout decides for itself what to draw (ZERO-5509).
676
701
  const lineRows = groupedItems.map((line) => {
677
702
  const lineCurrency = line.item.price_currency?.value || currency;
703
+ const attributes = line.item.product?.attributes;
704
+ const returnDetails = resolveReturnDetails(
705
+ line.item.active_cancellation_request
706
+ );
707
+ const lineStatusValue = String(line.item.status?.value ?? '');
678
708
 
679
709
  return {
680
710
  key: line.key,
@@ -694,7 +724,43 @@ const OrderDetailBlock = ({
694
724
  ...moneyStyle
695
725
  }),
696
726
  status: line.item.status?.label || '',
697
- statusValue: line.item.status?.value || ''
727
+ statusValue: lineStatusValue,
728
+ color: resolveAttribute(attributes, colorAttributeKey),
729
+ size: resolveAttribute(attributes, sizeAttributeKey),
730
+ trackingNumber: line.item.tracking_number || '',
731
+ trackingUrl: isDesigner ? '#' : line.item.tracking_url || '',
732
+ shippingCompany: line.item.shipping_company?.label || '',
733
+ returnCode: resolveReturnCode(line.item.active_cancellation_request),
734
+ returnMethod:
735
+ returnDetails.method === 'appointment'
736
+ ? appointmentReturnLabel
737
+ : returnDetails.method === 'standard'
738
+ ? standardReturnLabel
739
+ : '',
740
+ returnCarrier: returnDetails.carrier,
741
+ pickupDate: returnDetails.pickupDate
742
+ ? formatPickupDate(returnDetails.pickupDate, locale)
743
+ : '',
744
+ // The line seen as a one-line order, for blocks that act on an order
745
+ // from inside a row — the rate & review "Rate" action reads `id`,
746
+ // `number` and `items` off its row, so inside a line row it offers a
747
+ // review for that line's product only.
748
+ id: order.id ?? '',
749
+ number: order.number || orderKey,
750
+ createdDate: order.created_date || '',
751
+ items: [
752
+ {
753
+ id: line.item.id ?? line.key,
754
+ name: line.name,
755
+ image: line.image,
756
+ statusValue: lineStatusValue,
757
+ product: {
758
+ sku: line.item.product?.sku || '',
759
+ base_code: line.item.product?.base_code || '',
760
+ name: line.name
761
+ }
762
+ }
763
+ ]
698
764
  };
699
765
  });
700
766
 
@@ -778,6 +844,116 @@ const OrderDetailBlock = ({
778
844
  );
779
845
  };
780
846
 
847
+ // The designer's whole-order layout, when there is one: drawn once over
848
+ // the order instead of everything below. Hidden on the storefront when the
849
+ // author hid it, still drawn on the canvas so it can be designed.
850
+ const layoutChild =
851
+ (block.blocks || []).find(
852
+ (child) =>
853
+ readChildRole(child, currentBreakpoint) === ORDER_LAYOUT_ROLE &&
854
+ (isDesigner || !child.hidden)
855
+ ) ?? null;
856
+
857
+ if (layoutChild) {
858
+ const shippingLines = formatAddress(order.shipping_address);
859
+ const billingLines = formatAddress(order.billing_address);
860
+ const pieceCount = groupedItems.reduce(
861
+ (sum, line) => sum + Number(line.quantity || 0),
862
+ 0
863
+ );
864
+ const shippingAmount = Number(order.shipping_amount || 0);
865
+ const signedMoney = (value: unknown) => {
866
+ const amount = Math.abs(Number(value || 0));
867
+ return amount > 0 ? `-${money(String(amount))}` : money('0');
868
+ };
869
+ const deliveryLine = items.find((item) => item.estimated_delivery_date);
870
+ const linkOrInert = (href: string) => (isDesigner && href ? '#' : href);
871
+
872
+ // One row: the order. Every value is already formatted the way the block
873
+ // formats it (money style, locale date), so a text block bound to
874
+ // `item.total` prints "15.499 TL" exactly as the built-in header does.
875
+ const orderRows = [
876
+ {
877
+ key: String(order.id ?? order.number ?? orderKey),
878
+ id: order.id ?? '',
879
+ number: order.number || orderKey,
880
+ date: formatDate(order.created_date, locale),
881
+ createdDate: order.created_date || '',
882
+ status: order.status?.label || '',
883
+ statusValue: String(order.status?.value ?? ''),
884
+ total: money(order.amount),
885
+ pieceCount,
886
+ pieceSubtotal,
887
+ shipping: money(order.shipping_amount),
888
+ isFreeShipping: !(shippingAmount > 0),
889
+ discountTotal: signedMoney(order.discount_amount),
890
+ hasDiscount: Number(order.discount_amount || 0) !== 0,
891
+ discounts: (order.discountitem_set || []).map((discount, index) => ({
892
+ key: String(index),
893
+ name: discount.name || discountLabel,
894
+ amount: signedMoney(discount.amount)
895
+ })),
896
+ hasInstallments: installmentCount > 1,
897
+ installmentCount,
898
+ installmentAmount: perInstallment,
899
+ invoiceUrl: linkOrInert(order.e_archive_url || ''),
900
+ hasInvoice: Boolean(order.e_archive_url),
901
+ canCancel: offersCancel && Boolean(cancelHref),
902
+ canReturn: offersReturn && Boolean(cancelHref),
903
+ cancelUrl: linkOrInert(cancelHref),
904
+ trackingNumber: order.tracking_number || '',
905
+ trackingUrl: linkOrInert(order.tracking_url || ''),
906
+ hasTracking: Boolean(order.tracking_url || order.tracking_number),
907
+ shippingCompany: order.shipping_company?.label || '',
908
+ estimatedDelivery: deliveryLine
909
+ ? formatDate(deliveryLine.estimated_delivery_date, locale)
910
+ : '',
911
+ shippingName: shippingLines.length > 1 ? shippingLines[0] : '',
912
+ shippingAddress: shippingLines[shippingLines.length - 1] || '',
913
+ billingName: billingLines.length > 1 ? billingLines[0] : '',
914
+ billingAddress: billingLines[billingLines.length - 1] || '',
915
+ hasBillingAddress: billingLines.length > 0,
916
+ bankName: order.bank?.name || '',
917
+ paymentMethod: order.payment_option?.name || '',
918
+ lineCount: lineRows.length,
919
+ lines: lineRows,
920
+ // The order's delivered lines, for an order-wide rate & review block
921
+ // placed outside the line repeat.
922
+ items: lineRows.flatMap((row) => row.items)
923
+ }
924
+ ];
925
+
926
+ return (
927
+ <div
928
+ data-order-detail=""
929
+ data-order-detail-layout=""
930
+ style={{ width: '100%' }}
931
+ >
932
+ <ItemTemplateList
933
+ block={{
934
+ ...block,
935
+ blocks: [
936
+ {
937
+ ...layoutChild,
938
+ properties: {
939
+ ...(layoutChild.properties || {}),
940
+ role: ITEM_TEMPLATE_ROLE
941
+ }
942
+ }
943
+ ]
944
+ }}
945
+ items={orderRows}
946
+ placeholderId={placeholderId}
947
+ sectionId={sectionId}
948
+ isDesigner={isDesigner}
949
+ selectedBlockId={selectedBlockId}
950
+ currentBreakpoint={currentBreakpoint}
951
+ designerPlaceholderCount={1}
952
+ />
953
+ </div>
954
+ );
955
+ }
956
+
781
957
  return (
782
958
  <div
783
959
  data-order-detail=""
@@ -7,6 +7,7 @@ import { getInnerRootInlineStyles, partAttrs } from '../utils';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
8
  import { useLocalizeHref } from '../hooks/use-localize-href';
9
9
  import { localizeHtmlLinks } from '../utils/localize-href';
10
+ import { useValueLocale } from '../utils/localized-value';
10
11
  // Rich-text element defaults from the theme settings.
11
12
  import {
12
13
  THEME_RICH_TEXT_ATTRIBUTE,
@@ -8,6 +8,7 @@ import {
8
8
  type ThemeProduct
9
9
  } from './blocks/product-block-utils';
10
10
  import { DESIGNER_SAMPLE_PRODUCT } from './blocks/item-template-utils';
11
+ import { ThemeRowScopeProvider } from './blocks/row-scope';
11
12
  import {
12
13
  getInnerRootInlineStyles,
13
14
  getResponsiveValue,
@@ -67,6 +68,14 @@ export interface Block {
67
68
  * position a product card's click reports (ZERO-5305). Never authored.
68
69
  */
69
70
  itemScopeIndex?: number;
71
+ /**
72
+ * Set on the clones of a repeat INSIDE a row (an order's lines inside the
73
+ * order): the entry the clone draws, opened here as its row scope so a block
74
+ * reading `useThemeRowScope()` answers for that entry. Never authored —
75
+ * written by `buildIteratorBlock` (ZERO-5509).
76
+ */
77
+ itemScopeRow?: Record<string, unknown>;
78
+ itemScopeRowIndex?: number;
70
79
  iteratorDataPath?: string;
71
80
  /**
72
81
  * Written by the property-switch pass (utils/property-switches.ts), never
@@ -240,7 +249,22 @@ export default function ThemeBlock({
240
249
  </WithDesignerFeatures>
241
250
  );
242
251
 
243
- if (block.itemScopeProduct === undefined) return rendered;
252
+ // A clone of a repeat inside a row: its entry is the row for every block
253
+ // inside it (see `Block.itemScopeRow`). Context only, no element.
254
+ const scopedRendered =
255
+ block.itemScopeRow === undefined ? (
256
+ rendered
257
+ ) : (
258
+ <ThemeRowScopeProvider
259
+ listType="nested"
260
+ row={block.itemScopeRow}
261
+ index={block.itemScopeRowIndex ?? 0}
262
+ >
263
+ {rendered}
264
+ </ThemeRowScopeProvider>
265
+ );
266
+
267
+ if (block.itemScopeProduct === undefined) return scopedRendered;
244
268
 
245
269
  // A clone of a collection repeat: the row is the ambient product for every
246
270
  // block inside it (see `Block.itemScopeProduct`). Context only — the
@@ -254,7 +278,7 @@ export default function ThemeBlock({
254
278
  }
255
279
  index={block.itemScopeIndex}
256
280
  >
257
- {rendered}
281
+ {scopedRendered}
258
282
  </ThemeItemScopeProvider>
259
283
  );
260
284
  }
@@ -1726,6 +1726,17 @@ export const buildIteratorBlock = ({
1726
1726
  ),
1727
1727
  itemScopeIndex: index
1728
1728
  }
1729
+ : {}),
1730
+ // A repeat inside a row (an order's lines inside the order) draws rows
1731
+ // of its own: each clone opens a row scope for its entry, so a block
1732
+ // that acts on "its row" — the rate & review action — answers for that
1733
+ // entry rather than for the row around it (ZERO-5509). Only entries
1734
+ // that are records, and only real ones: padding rows have no row.
1735
+ ...(scopedItems !== null && isRecord(displayItems[index])
1736
+ ? {
1737
+ itemScopeRow: displayItems[index] as Record<string, unknown>,
1738
+ itemScopeRowIndex: index
1739
+ }
1729
1740
  : {})
1730
1741
  };
1731
1742