@akinon/pz-theme 2.0.154-rc.5 → 2.0.154-rc.7

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,21 @@
1
1
  # @akinon/pz-theme
2
2
 
3
+ ## 2.0.154-rc.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 51a88b89: ZERO-5183: Give the order cancellation screen its own placeholder (`account-order-cancellation-body`) so it can be composed in the theme editor. The `order-detail` block gains an "Easy return" link for orders that can be returned and, per returned line, the return method (scheduled pickup or standard), the carrier and the pickup date, each behind its own switch. Order rows now keep units of one product together only while they share a status and cancellation/return request, so returning one of two units no longer reads as both returned.
8
+ - @akinon/next@2.0.154-rc.7
9
+ - @akinon/theme-contract@2.0.154-rc.7
10
+
11
+ ## 2.0.154-rc.6
12
+
13
+ ### Patch Changes
14
+
15
+ - 8e040731: ZERO-5235: let brands hang actions on order detail lines
16
+ - @akinon/next@2.0.154-rc.6
17
+ - @akinon/theme-contract@2.0.154-rc.6
18
+
3
19
  ## 2.0.154-rc.5
4
20
 
5
21
  ### 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.154-rc.5",
4
+ "version": "2.0.154-rc.7",
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-rc.5",
11
+ "@akinon/next": "2.0.154-rc.7",
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-rc.5",
16
+ "@akinon/theme-contract": "2.0.154-rc.7",
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-rc.5",
24
+ "@akinon/next": "2.0.154-rc.7",
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",
@@ -27,6 +27,7 @@ const EXPECTED_MOUNTS: Record<string, string> = {
27
27
  accountDashboard: '(dashboard)/layout.tsx',
28
28
  accountOrders: 'orders/(list)/layout.tsx',
29
29
  accountOrderDetail: 'orders/[id]/(detail)/layout.tsx',
30
+ accountOrderCancellation: 'orders/[id]/cancellation/layout.tsx',
30
31
  accountAddresses: 'address/layout.tsx',
31
32
  accountProfile: 'profile/layout.tsx',
32
33
  accountChangePassword: 'change-password/layout.tsx',
@@ -61,6 +61,18 @@ describe('scoped custom CSS', () => {
61
61
  expect(css).toContain(`${SCOPE} button`);
62
62
  });
63
63
 
64
+ it('leaves commas inside :has(), :is() and attribute values alone', () => {
65
+ // ZERO-5183: the second half of `:has(a, b)` used to come out as
66
+ // `:has(a, <scope> b)` — a selector that can never match.
67
+ const css = scopeCustomCss(
68
+ '& li:has([data-a], [data-b]) { display: flex; }\n& :is(p, span)[title="x, y"], em { color: red; }',
69
+ SCOPE
70
+ );
71
+
72
+ expect(css).toContain(`${SCOPE} li:has([data-a], [data-b]) {`);
73
+ expect(css).toContain(`${SCOPE} :is(p, span)[title="x, y"], ${SCOPE} em {`);
74
+ });
75
+
64
76
  it('scopes inside a media query and keeps the condition', () => {
65
77
  const css = scopeCustomCss(
66
78
  '@media (max-width: 768px) { & [data-part="option"] { flex: 1 1 100%; } }',
@@ -80,6 +80,13 @@ describe('resolveBodySlug', () => {
80
80
  PLACEHOLDER_SLUGS.accountAddresses
81
81
  );
82
82
  expect(resolveBodySlug('/users/orders/5/cancellation')).toBe(
83
+ PLACEHOLDER_SLUGS.accountOrderCancellation
84
+ );
85
+ expect(resolveBodySlug('/account/orders/5/cancellation/')).toBe(
86
+ PLACEHOLDER_SLUGS.accountOrderCancellation
87
+ );
88
+ // Any other screen under an order stays on the detail body.
89
+ expect(resolveBodySlug('/account/orders/5/invoice')).toBe(
83
90
  PLACEHOLDER_SLUGS.accountOrderDetail
84
91
  );
85
92
  });
@@ -0,0 +1,87 @@
1
+ import { groupOrderItems } from '../product-block-utils';
2
+
3
+ /**
4
+ * ZERO-5183 — pz writes one order line per unit and the order blocks collapse
5
+ * the units of a product into one row. A row shows its first unit's status and
6
+ * return details, so units only share it while they are in the same state:
7
+ * returning 1 of 2 must not read as both returned.
8
+ */
9
+ const product = { pk: 7, name: 'Gömlek', image: '/x.jpg', absolute_url: '#' };
10
+
11
+ const unit = (id: number, extra: Record<string, unknown> = {}) => ({
12
+ id,
13
+ price: '100.00',
14
+ quantity: 1,
15
+ status: { value: '550', label: 'Teslim Edildi' },
16
+ active_cancellation_request: null,
17
+ product,
18
+ ...extra
19
+ });
20
+
21
+ describe('groupOrderItems', () => {
22
+ it('still collapses units of a product that share a state', () => {
23
+ const rows = groupOrderItems([unit(1), unit(2), unit(3)]);
24
+
25
+ expect(rows).toHaveLength(1);
26
+ expect(rows[0]).toMatchObject({ key: '7', quantity: 3, total: 300 });
27
+ });
28
+
29
+ it('gives a returned unit its own row', () => {
30
+ const returned = unit(2, {
31
+ status: { value: '600', label: 'İade Edildi' },
32
+ active_cancellation_request: { easy_return: { code: 'R1' } }
33
+ });
34
+ const rows = groupOrderItems([unit(1), returned]);
35
+
36
+ expect(rows.map((row) => [row.key, row.quantity, row.item.id])).toEqual([
37
+ ['7', 1, 1],
38
+ ['7-2', 1, 2]
39
+ ]);
40
+ });
41
+
42
+ it('splits on a pending request even before the status moves', () => {
43
+ const requested = unit(2, {
44
+ active_cancellation_request: [{ id: 31, easy_return: { code: 'R1' } }]
45
+ });
46
+
47
+ expect(groupOrderItems([unit(1), requested])).toHaveLength(2);
48
+ });
49
+
50
+ it('keeps units returned under the same request together, and separate requests apart', () => {
51
+ const returned = (id: number, code: string) =>
52
+ unit(id, {
53
+ status: { value: '600', label: 'İade Edildi' },
54
+ active_cancellation_request: { easy_return: { code } }
55
+ });
56
+
57
+ expect(
58
+ groupOrderItems([returned(1, 'R1'), returned(2, 'R1')]).map(
59
+ (row) => row.quantity
60
+ )
61
+ ).toEqual([2]);
62
+ expect(
63
+ groupOrderItems([returned(1, 'R1'), returned(2, 'R2')]).map(
64
+ (row) => row.key
65
+ )
66
+ ).toEqual(['7', '7-2']);
67
+ });
68
+
69
+ it('merges a later unit back into the row of its state', () => {
70
+ const cancelled = unit(2, { status: { value: '100', label: 'İptal' } });
71
+ const rows = groupOrderItems([unit(1), cancelled, unit(3)]);
72
+
73
+ expect(rows.map((row) => [row.key, row.quantity])).toEqual([
74
+ ['7', 2],
75
+ ['7-2', 1]
76
+ ]);
77
+ });
78
+
79
+ it('never merges lines without a product pk', () => {
80
+ const rows = groupOrderItems([
81
+ { id: 1, price: '1', quantity: 1, product: { name: 'A' } },
82
+ { id: 2, price: '1', quantity: 1, product: { name: 'A' } }
83
+ ]);
84
+
85
+ expect(rows.map((row) => row.key)).toEqual(['1', '2']);
86
+ });
87
+ });
@@ -0,0 +1,208 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import OrderDetailBlock from '../order-detail-block';
4
+
5
+ /**
6
+ * ZERO-5183 — the order detail is where an easy return starts and where a
7
+ * returned line says how it goes back: a "Kolay iade" link on an order that
8
+ * can be returned, and per line the method (a booked pickup or a standard
9
+ * drop-off), the carrier and the pickup day, each behind its own switch.
10
+ */
11
+ let order: Record<string, unknown> | null = null;
12
+
13
+ jest.mock('next/navigation', () => ({
14
+ useParams: () => ({ id: '42' }),
15
+ useSearchParams: () => null
16
+ }));
17
+
18
+ jest.mock('next-auth/react', () => ({
19
+ useSession: () => ({ data: null, status: 'authenticated' })
20
+ }));
21
+
22
+ jest.mock('@akinon/next/hooks', () => ({
23
+ __esModule: true,
24
+ useLocalization: () => ({ locale: 'tr', currency: 'try' })
25
+ }));
26
+
27
+ jest.mock('@akinon/next/data/client/account', () => ({
28
+ __esModule: true,
29
+ useGetOrderQuery: () => ({ data: order, isLoading: false })
30
+ }));
31
+
32
+ const line = (id: number, request: unknown) => ({
33
+ id,
34
+ price: '5499.00',
35
+ quantity: 1,
36
+ status: { value: '600', label: 'İade Edildi' },
37
+ active_cancellation_request: request,
38
+ product: { pk: id, name: `Gömlek ${id}`, image: '/x.jpg', absolute_url: '#' }
39
+ });
40
+
41
+ const blockOf = (properties: Record<string, unknown>) => ({
42
+ id: 'order-detail-1',
43
+ type: 'order-detail',
44
+ properties,
45
+ styles: {},
46
+ blocks: []
47
+ });
48
+
49
+ const ALL_ON = {
50
+ showReturnLink: { desktop: true },
51
+ returnLinkLabel: { desktop: 'Kolay iade' },
52
+ showReturnMethod: { desktop: true },
53
+ returnMethodLabel: { desktop: 'İade yöntemi:' },
54
+ appointmentReturnLabel: { desktop: 'Randevulu' },
55
+ standardReturnLabel: { desktop: 'Standart' },
56
+ showReturnCarrier: { desktop: true },
57
+ returnCarrierLabel: { desktop: 'Kargo:' },
58
+ showPickupDate: { desktop: true },
59
+ pickupDateLabel: { desktop: 'Randevu tarihi:' }
60
+ };
61
+
62
+ const texts = (root: ParentNode, part: string) =>
63
+ Array.from(root.querySelectorAll(`[data-part="${part}"]`)).map((e) =>
64
+ (e.textContent || '').replace(/\s+/g, ' ').trim()
65
+ );
66
+
67
+ beforeEach(() => {
68
+ order = {
69
+ id: 42,
70
+ number: 'G-42',
71
+ amount: '10998.00',
72
+ is_cancellable: false,
73
+ is_refundable: true,
74
+ status: { value: '550', label: 'Teslim edildi' },
75
+ orderitem_set: [
76
+ line(1, [
77
+ {
78
+ pickup_date: { date: '2026-04-11' },
79
+ easy_return: {
80
+ code: 'R-1',
81
+ shipping_company: { label: 'Hepsijet' }
82
+ }
83
+ }
84
+ ]),
85
+ line(2, {
86
+ easy_return: {
87
+ code: 'R-2',
88
+ shipping_company: { label: 'Yurtiçi Kargo' }
89
+ }
90
+ }),
91
+ { ...line(3, null), status: { value: '550', label: 'Teslim edildi' } }
92
+ ]
93
+ };
94
+ });
95
+
96
+ describe('order detail — easy returns', () => {
97
+ it('links a returnable order to the easy-return screen', () => {
98
+ const { container } = render(
99
+ <OrderDetailBlock
100
+ block={blockOf(ALL_ON) as never}
101
+ placeholderId="p1"
102
+ sectionId="s1"
103
+ isDesigner={false}
104
+ />
105
+ );
106
+ const link = container.querySelector('[data-part="return-link"]');
107
+
108
+ expect(link?.textContent).toContain('Kolay iade');
109
+ expect(link?.getAttribute('href')).toBe('/account/orders/42/cancellation');
110
+ // The cancel link stays for orders that can still be cancelled.
111
+ expect(container.querySelector('[data-part="cancel-link"]')).toBeNull();
112
+ });
113
+
114
+ it('says how each returned line goes back', () => {
115
+ const { container } = render(
116
+ <OrderDetailBlock
117
+ block={blockOf(ALL_ON) as never}
118
+ placeholderId="p1"
119
+ sectionId="s1"
120
+ isDesigner={false}
121
+ />
122
+ );
123
+
124
+ expect(texts(container, 'item-return-method')).toEqual([
125
+ 'İade yöntemi: Randevulu',
126
+ 'İade yöntemi: Standart'
127
+ ]);
128
+ expect(texts(container, 'item-return-carrier')).toEqual([
129
+ 'Kargo: Hepsijet',
130
+ 'Kargo: Yurtiçi Kargo'
131
+ ]);
132
+ expect(texts(container, 'item-pickup-date')).toEqual([
133
+ 'Randevu tarihi: 11.04.2026'
134
+ ]);
135
+ });
136
+
137
+ it('keeps every new line behind its switch', () => {
138
+ const { container } = render(
139
+ <OrderDetailBlock
140
+ block={blockOf({}) as never}
141
+ placeholderId="p1"
142
+ sectionId="s1"
143
+ isDesigner={false}
144
+ />
145
+ );
146
+
147
+ expect(container.querySelector('[data-part="return-link"]')).toBeNull();
148
+ expect(texts(container, 'item-return-method')).toEqual([]);
149
+ expect(texts(container, 'item-return-carrier')).toEqual([]);
150
+ expect(texts(container, 'item-pickup-date')).toEqual([]);
151
+ });
152
+
153
+ it('offers no return link on an order that cannot be returned', () => {
154
+ order = { ...order, is_refundable: false };
155
+ const { container } = render(
156
+ <OrderDetailBlock
157
+ block={blockOf(ALL_ON) as never}
158
+ placeholderId="p1"
159
+ sectionId="s1"
160
+ isDesigner={false}
161
+ />
162
+ );
163
+
164
+ expect(container.querySelector('[data-part="return-link"]')).toBeNull();
165
+ });
166
+
167
+ it('links only a delivered line that has no request yet', () => {
168
+ // As commerce answers after a return: the order and its lines stay
169
+ // refundable while the request is open.
170
+ const flagged: Record<string, unknown>[] = (
171
+ order.orderitem_set as Record<string, unknown>[]
172
+ ).map((item) => ({ ...item, is_refundable: true }));
173
+ const returnLink = () =>
174
+ render(
175
+ <OrderDetailBlock
176
+ block={blockOf(ALL_ON) as never}
177
+ placeholderId="p1"
178
+ sectionId="s1"
179
+ isDesigner={false}
180
+ />
181
+ ).container.querySelector('[data-part="return-link"]');
182
+
183
+ // Line 3 has no request yet: it can still be returned.
184
+ order = { ...order, orderitem_set: flagged };
185
+ expect(returnLink()).not.toBeNull();
186
+
187
+ order = {
188
+ ...order,
189
+ orderitem_set: flagged.map((item) =>
190
+ item.id === 3
191
+ ? { ...item, active_cancellation_request: { id: 54766 } }
192
+ : item
193
+ )
194
+ };
195
+ expect(returnLink()).toBeNull();
196
+
197
+ // Commerce calls a shipped line refundable too; it is not returned yet.
198
+ order = {
199
+ ...order,
200
+ orderitem_set: flagged.map((item) =>
201
+ item.id === 3
202
+ ? { ...item, status: { value: '500', label: 'Gönderildi' } }
203
+ : item
204
+ )
205
+ };
206
+ expect(returnLink()).toBeNull();
207
+ });
208
+ });
@@ -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
+ });
@@ -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;
@@ -26,6 +27,7 @@ interface OrderItemLike {
26
27
  tracking_number?: string;
27
28
  estimated_delivery_date?: string;
28
29
  price_currency?: { value?: string; label?: string };
30
+ is_refundable?: boolean;
29
31
  active_cancellation_request?: unknown;
30
32
  product?: {
31
33
  pk?: number;
@@ -34,7 +36,7 @@ interface OrderItemLike {
34
36
  absolute_url?: string;
35
37
  attributes?: Record<string, unknown>;
36
38
  } | null;
37
- }
39
+ };
38
40
 
39
41
  interface AddressLike {
40
42
  first_name?: string;
@@ -65,6 +67,7 @@ interface OrderLike {
65
67
  orderitem_set?: OrderItemLike[];
66
68
  discountitem_set?: DiscountItemLike[];
67
69
  is_cancellable?: boolean;
70
+ is_refundable?: boolean;
68
71
  e_archive_url?: string | null;
69
72
  shipping_address?: AddressLike | null;
70
73
  billing_address?: AddressLike | null;
@@ -112,6 +115,37 @@ const firstString = (
112
115
  return '';
113
116
  };
114
117
 
118
+ const hasOpenRequest = (request: unknown): boolean =>
119
+ Array.isArray(request) ? request.length > 0 : Boolean(request);
120
+
121
+ /** Commerce's "Teslim edildi". */
122
+ const DELIVERED = '550';
123
+
124
+ /**
125
+ * Whether the order still has a line to return: delivered, refundable, with
126
+ * no request open on it. Commerce also marks a shipped line refundable, and
127
+ * keeps `is_refundable` true on the order and the line while a return request
128
+ * is open, so the order flag alone would offer a return before the parcel
129
+ * arrives and again after the shopper has asked. Lines that carry no flag at
130
+ * all (an older payload) leave the decision to the order's flag and status.
131
+ */
132
+ const hasReturnableLine = (order: OrderLike): boolean => {
133
+ if (!order.is_refundable) return false;
134
+
135
+ const lines = order.orderitem_set ?? [];
136
+
137
+ if (!lines.some((item) => item && 'is_refundable' in item)) {
138
+ return String(order.status?.value ?? '') === DELIVERED;
139
+ }
140
+
141
+ return lines.some(
142
+ (item) =>
143
+ String(item?.status?.value ?? '') === DELIVERED &&
144
+ item?.is_refundable &&
145
+ !hasOpenRequest(item.active_cancellation_request)
146
+ );
147
+ };
148
+
115
149
  /** Positive integers only; anything else falls back to the shipped size. */
116
150
  const readSize = (value: unknown, fallback: number): number => {
117
151
  const numeric = Math.round(Number(value));
@@ -145,6 +179,7 @@ const DESIGNER_SAMPLE_ORDER: OrderLike = {
145
179
  status: { value: '10', label: 'Approved' },
146
180
  currency: { value: 'try', label: 'TRY' },
147
181
  is_cancellable: true,
182
+ is_refundable: true,
148
183
  e_archive_url: '#',
149
184
  installment_count: 3,
150
185
  orderitem_set: [
@@ -158,7 +193,16 @@ const DESIGNER_SAMPLE_ORDER: OrderLike = {
158
193
  tracking_number: '1Z999AA10123456784',
159
194
  tracking_url: '#',
160
195
  active_cancellation_request: [
161
- { easy_return: { code: '12345678912345' } }
196
+ {
197
+ easy_return: {
198
+ code: '12345678912345',
199
+ shipping_company: { value: 'carrier', label: 'Carrier' }
200
+ },
201
+ pickup_date: {
202
+ date: '2026-04-11',
203
+ hour_range: { start: '09:00:00', end: '18:00:00' }
204
+ }
205
+ }
162
206
  ],
163
207
  product: {
164
208
  pk: 1,
@@ -249,6 +293,63 @@ const resolveReturnCode = (value: unknown): string => {
249
293
  return typeof code === 'string' ? code : '';
250
294
  };
251
295
 
296
+ interface ReturnRequestLike {
297
+ pickup_date?: { date?: string } | null;
298
+ easy_return?: {
299
+ code?: string;
300
+ shipping_company?: { label?: string } | null;
301
+ } | null;
302
+ }
303
+
304
+ /**
305
+ * How a returned line goes back: `appointment` when the request booked a
306
+ * pickup (it carries a pickup day), `standard` for an easy return dropped off
307
+ * at a branch, nothing for a plain cancellation. The carrier and the pickup
308
+ * day come from the same request; both shapes of the field are probed, as
309
+ * for the return code.
310
+ */
311
+ const resolveReturnDetails = (
312
+ value: unknown
313
+ ): {
314
+ method: 'appointment' | 'standard' | null;
315
+ carrier: string;
316
+ pickupDate: string;
317
+ } => {
318
+ const entry = (Array.isArray(value) ? value[0] : value) as
319
+ | ReturnRequestLike
320
+ | null
321
+ | undefined;
322
+ const pickupDate = entry?.pickup_date?.date;
323
+ const carrier = entry?.easy_return?.shipping_company?.label;
324
+
325
+ return {
326
+ method: pickupDate ? 'appointment' : entry?.easy_return ? 'standard' : null,
327
+ carrier: typeof carrier === 'string' ? carrier : '',
328
+ pickupDate: typeof pickupDate === 'string' ? pickupDate : ''
329
+ };
330
+ };
331
+
332
+ /** "2026-04-11" read as a calendar day in the page's locale (no time zone shift). */
333
+ const formatPickupDate = (value: string, locale?: string): string => {
334
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
335
+ if (!match) return value;
336
+
337
+ try {
338
+ return new Intl.DateTimeFormat(locale || undefined, {
339
+ timeZone: 'UTC',
340
+ day: '2-digit',
341
+ month: '2-digit',
342
+ year: 'numeric'
343
+ }).format(
344
+ new Date(
345
+ Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12)
346
+ )
347
+ );
348
+ } catch {
349
+ return value;
350
+ }
351
+ };
352
+
252
353
  const formatAddress = (address: AddressLike | null | undefined): string[] => {
253
354
  if (!address) return [];
254
355
  const name = [address.first_name, address.last_name]
@@ -272,14 +373,18 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
272
373
  * order key is read from the URL; the editor owns which sections are exposed,
273
374
  * every caption and the styling.
274
375
  * A designer can mark one child as the row layout for the order lines; without
275
- * one the built-in line renders.
376
+ * one the built-in line renders. A brand may hang per-line actions on the
377
+ * built-in line through `registerOrderLineActions`; they render inside the
378
+ * line's `item-actions` part.
276
379
  * Properties: numberLabel, numberTag, dateLabel, headerTotalLabel,
277
380
  * showInvoiceLink, invoiceLinkLabel, showCancelLink, cancelLinkLabel,
278
- * cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
381
+ * showReturnLink, returnLinkLabel, cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
279
382
  * imageHeight, showQuantity, quantityLabel, showColor,
280
383
  * colorLabel, colorAttributeKey, showSize, sizeLabel, sizeAttributeKey,
281
384
  * showLineTracking, lineTrackingLabel, showReturnCode,
282
- * returnCodeLabel, showShippingAddress, shippingAddressLabel,
385
+ * returnCodeLabel, showReturnMethod, returnMethodLabel,
386
+ * appointmentReturnLabel, standardReturnLabel, showReturnCarrier,
387
+ * returnCarrierLabel, showPickupDate, pickupDateLabel, showShippingAddress, shippingAddressLabel,
283
388
  * showBillingAddress, billingAddressLabel, showTotals, showPieceCount,
284
389
  * pieceCountLabel, showDiscountItems, showInstallmentInfo,
285
390
  * installmentInfoLabel, subtotalLabel, discountLabel, shippingLabel,
@@ -287,9 +392,10 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
287
392
  * showPaymentInfo, paymentInfoLabel, decimals, currencyDisplay,
288
393
  * currencyText, currencyPosition.
289
394
  * Parts: detail, header, number, date, header-total, status,
290
- * invoice-link, cancel-link, list, item, image, name, quantity, color, size,
291
- * item-status, item-tracking, item-return-code, price, shipment,
292
- * delivery, tracking, addresses, address, address-label, totals, total-row,
395
+ * invoice-link, cancel-link, return-link, list, item, image, name, quantity,
396
+ * color, size, item-status, item-tracking, item-return-code,
397
+ * item-return-method, item-return-carrier, item-pickup-date, item-actions,
398
+ * price, shipment, delivery, tracking, addresses, address, address-label, totals, total-row,
293
399
  * total-label, total-value, grand-total, payment-info, payment-info-label.
294
400
  */
295
401
  const OrderDetailBlock = ({
@@ -399,6 +505,38 @@ const OrderDetailBlock = ({
399
505
  false
400
506
  );
401
507
  const returnCodeLabel = text('returnCodeLabel', 'Return code:');
508
+ // ZERO-5183: the easy-return screens start from here, and a returned line
509
+ // says how it goes back. The cancel link only shows while the order can
510
+ // still be cancelled; a delivered order needs its own way in.
511
+ const showReturnLink = readToggle(
512
+ block.properties?.showReturnLink,
513
+ currentBreakpoint,
514
+ false
515
+ );
516
+ const returnLinkLabel = text('returnLinkLabel', 'Easy return');
517
+ const showReturnMethod = readToggle(
518
+ block.properties?.showReturnMethod,
519
+ currentBreakpoint,
520
+ false
521
+ );
522
+ const returnMethodLabel = text('returnMethodLabel', 'Return method:');
523
+ const appointmentReturnLabel = text(
524
+ 'appointmentReturnLabel',
525
+ 'Scheduled pickup'
526
+ );
527
+ const standardReturnLabel = text('standardReturnLabel', 'Standard');
528
+ const showReturnCarrier = readToggle(
529
+ block.properties?.showReturnCarrier,
530
+ currentBreakpoint,
531
+ false
532
+ );
533
+ const returnCarrierLabel = text('returnCarrierLabel', 'Carrier:');
534
+ const showPickupDate = readToggle(
535
+ block.properties?.showPickupDate,
536
+ currentBreakpoint,
537
+ false
538
+ );
539
+ const pickupDateLabel = text('pickupDateLabel', 'Pickup date:');
402
540
 
403
541
  const showShippingAddress = readToggle(
404
542
  block.properties?.showShippingAddress,
@@ -486,6 +624,12 @@ const OrderDetailBlock = ({
486
624
  const money = (value: unknown) =>
487
625
  formatMoney(value, { locale, currency, ...moneyStyle });
488
626
 
627
+ // The sample order is not a delivered one; it shows the link for styling.
628
+ const offersReturn =
629
+ order === DESIGNER_SAMPLE_ORDER
630
+ ? Boolean(order.is_refundable)
631
+ : hasReturnableLine(order);
632
+
489
633
  // Navigating inside the designer iframe would drop the author out of the page
490
634
  // they are editing, so every link is inert there.
491
635
  const guard = (event: React.MouseEvent) => {
@@ -664,7 +808,7 @@ const OrderDetailBlock = ({
664
808
  {headerTotalLabel} {money(order.amount)}
665
809
  </span>
666
810
  )}
667
- {(showInvoiceLink || showCancelLink) && (
811
+ {(showInvoiceLink || showCancelLink || showReturnLink) && (
668
812
  <div
669
813
  style={{
670
814
  display: 'flex',
@@ -704,6 +848,21 @@ const OrderDetailBlock = ({
704
848
  → {cancelLinkLabel}
705
849
  </NextLink>
706
850
  )}
851
+ {showReturnLink && offersReturn && cancelHref && (
852
+ <NextLink
853
+ href={isDesigner ? '#' : cancelHref}
854
+ onClick={guard}
855
+ data-order-detail-return-link=""
856
+ {...partAttrs('return-link')}
857
+ style={{
858
+ color: 'inherit',
859
+ textDecoration: 'none',
860
+ whiteSpace: 'nowrap'
861
+ }}
862
+ >
863
+ → {returnLinkLabel}
864
+ </NextLink>
865
+ )}
707
866
  </div>
708
867
  )}
709
868
  </div>
@@ -758,6 +917,37 @@ const OrderDetailBlock = ({
758
917
  const returnCodeValue = showReturnCode
759
918
  ? resolveReturnCode(item.active_cancellation_request)
760
919
  : '';
920
+ const returnDetails = resolveReturnDetails(
921
+ item.active_cancellation_request
922
+ );
923
+ const returnMethodValue =
924
+ showReturnMethod && returnDetails.method
925
+ ? returnDetails.method === 'appointment'
926
+ ? appointmentReturnLabel
927
+ : standardReturnLabel
928
+ : '';
929
+ const returnCarrierValue = showReturnCarrier
930
+ ? returnDetails.carrier
931
+ : '';
932
+ const pickupDateValue =
933
+ showPickupDate && returnDetails.pickupDate
934
+ ? formatPickupDate(returnDetails.pickupDate, locale)
935
+ : '';
936
+ const lineActions = renderOrderLineActions({
937
+ order,
938
+ line,
939
+ isDesigner
940
+ });
941
+ const showLineMeta =
942
+ showShipmentStatus &&
943
+ Boolean(
944
+ item.status?.label ||
945
+ trackingValue ||
946
+ returnCodeValue ||
947
+ returnMethodValue ||
948
+ returnCarrierValue ||
949
+ pickupDateValue
950
+ );
761
951
 
762
952
  return (
763
953
  <li
@@ -873,63 +1063,100 @@ const OrderDetailBlock = ({
873
1063
  </div>
874
1064
  </div>
875
1065
 
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
- )}
1066
+ {(showLineMeta || lineActions) && (
1067
+ <div
1068
+ style={{
1069
+ display: 'flex',
1070
+ flexDirection: 'column',
1071
+ alignItems: isMobile ? 'flex-start' : 'flex-end',
1072
+ textAlign: isMobile ? 'left' : 'right',
1073
+ gap: '4px',
1074
+ flex: isMobile ? '1 1 100%' : '0 0 auto'
1075
+ }}
1076
+ >
1077
+ {lineActions && (
1078
+ <div
1079
+ data-order-detail-item-actions=""
1080
+ {...partAttrs('item-actions')}
1081
+ >
1082
+ {lineActions}
1083
+ </div>
1084
+ )}
1085
+ {showLineMeta && item.status?.label && (
1086
+ <span
1087
+ data-order-detail-item-status=""
1088
+ data-order-status-value={item.status.value}
1089
+ {...partAttrs('item-status')}
1090
+ style={{ display: 'inline-block' }}
1091
+ >
1092
+ {item.status.label}
1093
+ </span>
1094
+ )}
1095
+ {/* Label and value in their own spans, so a
1096
+ composition can colour them apart. */}
1097
+ {showLineMeta && returnMethodValue && (
1098
+ <span
1099
+ data-order-detail-item-return-method=""
1100
+ {...partAttrs('item-return-method')}
1101
+ style={{ opacity: 0.8 }}
1102
+ >
1103
+ <span data-label="">{returnMethodLabel}</span>{' '}
1104
+ <span data-value="">{returnMethodValue}</span>
1105
+ </span>
1106
+ )}
1107
+ {showLineMeta && returnCarrierValue && (
1108
+ <span
1109
+ data-order-detail-item-return-carrier=""
1110
+ {...partAttrs('item-return-carrier')}
1111
+ style={{ opacity: 0.8 }}
1112
+ >
1113
+ <span data-label="">{returnCarrierLabel}</span>{' '}
1114
+ <span data-value="">{returnCarrierValue}</span>
1115
+ </span>
1116
+ )}
1117
+ {showLineMeta && pickupDateValue && (
1118
+ <span
1119
+ data-order-detail-item-pickup-date=""
1120
+ {...partAttrs('item-pickup-date')}
1121
+ style={{ opacity: 0.8 }}
1122
+ >
1123
+ <span data-label="">{pickupDateLabel}</span>{' '}
1124
+ <span data-value="">{pickupDateValue}</span>
1125
+ </span>
1126
+ )}
1127
+ {showLineMeta && trackingValue && (
1128
+ <span
1129
+ data-order-detail-item-tracking=""
1130
+ {...partAttrs('item-tracking')}
1131
+ style={{ opacity: 0.8 }}
1132
+ >
1133
+ {lineTrackingLabel}{' '}
1134
+ {item.tracking_url ? (
1135
+ <a
1136
+ href={isDesigner ? '#' : item.tracking_url}
1137
+ onClick={guard}
1138
+ target={isDesigner ? undefined : '_blank'}
1139
+ rel="noreferrer"
1140
+ style={{ color: 'inherit' }}
1141
+ >
1142
+ {item.tracking_number || item.tracking_url}
1143
+ </a>
1144
+ ) : (
1145
+ trackingValue
1146
+ )}
1147
+ </span>
1148
+ )}
1149
+ {showLineMeta && returnCodeValue && (
1150
+ <span
1151
+ data-order-detail-item-return-code=""
1152
+ {...partAttrs('item-return-code')}
1153
+ style={{ opacity: 0.8 }}
1154
+ >
1155
+ {returnCodeLabel} {returnCodeValue}
1156
+ </span>
1157
+ )}
1158
+ </div>
1159
+ )}
933
1160
  </div>
934
1161
  </li>
935
1162
  );
@@ -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
+ };
@@ -240,6 +240,8 @@ export interface OrderLineLike {
240
240
  price?: string | number;
241
241
  quantity?: string | number;
242
242
  total_amount?: string | number;
243
+ status?: { value?: string | number; label?: string } | null;
244
+ active_cancellation_request?: unknown;
243
245
  product?: {
244
246
  pk?: number;
245
247
  name?: string;
@@ -278,7 +280,10 @@ export const getLineImage = (
278
280
  };
279
281
 
280
282
  export interface GroupedOrderLine<T extends OrderLineLike> {
281
- /** Stable React key: the product pk when the payload carries one. */
283
+ /**
284
+ * Stable React key: the product pk when the payload carries one (with a
285
+ * `-2`, `-3`… suffix for the product's further rows, see groupOrderItems).
286
+ */
282
287
  key: string;
283
288
  name: string;
284
289
  image: string;
@@ -301,21 +306,28 @@ export interface GroupedOrderLine<T extends OrderLineLike> {
301
306
  * `quantity` is summed rather than counted so a payload that does carry a line
302
307
  * quantity (older commerce versions, and brands that merge lines server-side)
303
308
  * is respected instead of being flattened back to one.
309
+ *
310
+ * Units of one product only share a row while nothing sets them apart: once
311
+ * one of them is cancelled or returned (its status moves on, or it carries a
312
+ * cancellation / return request) it gets a row of its own. A shared row shows
313
+ * its first unit's status and return details, so "return 1 of 2" would
314
+ * otherwise read as both units returned.
304
315
  */
305
316
  export const groupOrderItems = <T extends OrderLineLike>(
306
317
  items: T[] | null | undefined
307
318
  ): GroupedOrderLine<T>[] => {
308
319
  const source = Array.isArray(items) ? items : [];
320
+ const groups: GroupedOrderLine<T>[] = [];
321
+ const byIdentity = new Map<string, GroupedOrderLine<T>>();
322
+ const rowsPerKey = new Map<string, number>();
309
323
 
310
- return source.reduce<GroupedOrderLine<T>[]>((accumulator, item) => {
324
+ source.forEach((item) => {
311
325
  const pk = item.product?.pk;
312
- const key = String(pk ?? item.id ?? accumulator.length);
313
326
  // Lines without a product pk cannot be proven identical, so they stay
314
327
  // separate rows instead of collapsing into an arbitrary neighbour.
315
- const existing =
316
- pk === undefined || pk === null
317
- ? undefined
318
- : accumulator.find((entry) => entry.key === key);
328
+ const identity =
329
+ pk === undefined || pk === null ? null : `${pk}#${lineState(item)}`;
330
+ const existing = identity === null ? undefined : byIdentity.get(identity);
319
331
  const quantity = parseAmount(item.quantity) ?? 1;
320
332
  const lineTotal = parseAmount(item.total_amount || item.price);
321
333
 
@@ -324,11 +336,15 @@ export const groupOrderItems = <T extends OrderLineLike>(
324
336
  existing.total =
325
337
  lineTotal === null ? existing.total : (existing.total ?? 0) + lineTotal;
326
338
 
327
- return accumulator;
339
+ return;
328
340
  }
329
341
 
330
- accumulator.push({
331
- key,
342
+ const baseKey = String(pk ?? item.id ?? groups.length);
343
+ const rows = (rowsPerKey.get(baseKey) ?? 0) + 1;
344
+ rowsPerKey.set(baseKey, rows);
345
+
346
+ const group: GroupedOrderLine<T> = {
347
+ key: rows === 1 ? baseKey : `${baseKey}-${rows}`,
332
348
  name: item.product?.name || '',
333
349
  image: getLineImage(item),
334
350
  href: item.product?.absolute_url || '',
@@ -336,10 +352,30 @@ export const groupOrderItems = <T extends OrderLineLike>(
336
352
  total: lineTotal,
337
353
  quantity,
338
354
  item
339
- });
355
+ };
356
+ groups.push(group);
357
+ if (identity !== null) byIdentity.set(identity, group);
358
+ });
359
+
360
+ return groups;
361
+ };
362
+
363
+ /**
364
+ * What tells one unit of a product apart from another in the same order: its
365
+ * status and the cancellation / return request it carries (one object or a
366
+ * one-item array, depending on the pz version).
367
+ */
368
+ const lineState = (item: OrderLineLike): string => {
369
+ const value = item.active_cancellation_request;
370
+ const request = (Array.isArray(value) ? value[0] : value) as
371
+ | { id?: unknown; easy_return?: { code?: unknown } | null }
372
+ | null
373
+ | undefined;
374
+ const requestKey = request
375
+ ? String(request.id ?? request.easy_return?.code ?? 'request')
376
+ : '';
340
377
 
341
- return accumulator;
342
- }, []);
378
+ return `${item.status?.value ?? ''}|${requestKey}`;
343
379
  };
344
380
 
345
381
  /**
@@ -29,6 +29,10 @@ export const PLACEHOLDER_SLUGS = {
29
29
  accountDashboard: 'account-dashboard-body',
30
30
  accountOrders: 'account-orders-body',
31
31
  accountOrderDetail: 'account-order-detail-body',
32
+ // The easy-return / cancellation screen under one order. It used to resolve
33
+ // to the detail slug and stay code-owned; its own slug lets a brand compose
34
+ // it without the detail composition swallowing it.
35
+ accountOrderCancellation: 'account-order-cancellation-body',
32
36
  accountAddresses: 'account-address-body',
33
37
  accountProfile: 'account-profile-body',
34
38
  accountChangePassword: 'account-change-password-body',
@@ -19,9 +19,12 @@ export type RouteSlugRule = [RegExp, string];
19
19
  * resolves to the order detail body and not to the order list.
20
20
  */
21
21
  const DEFAULT_RULES: RouteSlugRule[] = [
22
- // Account — deepest first. The detail rule also matches deeper order
23
- // screens (e.g. cancellation); the reference app only mounts the slot on
24
- // the detail screen itself, so those stay code-owned.
22
+ // Account — deepest first. The cancellation screen under an order has its
23
+ // own body; the detail rule still matches any other deeper order screen.
24
+ [
25
+ /^\/(account|users)\/orders\/[^/]+\/cancellation(\/|$)/,
26
+ PLACEHOLDER_SLUGS.accountOrderCancellation
27
+ ],
25
28
  [
26
29
  /^\/(account|users)\/orders\/[^/]+/,
27
30
  PLACEHOLDER_SLUGS.accountOrderDetail
@@ -130,6 +133,7 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
130
133
  PLACEHOLDER_SLUGS.accountDashboard,
131
134
  PLACEHOLDER_SLUGS.accountOrders,
132
135
  PLACEHOLDER_SLUGS.accountOrderDetail,
136
+ PLACEHOLDER_SLUGS.accountOrderCancellation,
133
137
  PLACEHOLDER_SLUGS.accountAddresses,
134
138
  PLACEHOLDER_SLUGS.accountProfile,
135
139
  PLACEHOLDER_SLUGS.accountChangePassword,
@@ -77,9 +77,42 @@ const splitTopLevelRules = (css: string): string[] => {
77
77
  return rules.map(rule => rule.trim()).filter(Boolean);
78
78
  };
79
79
 
80
+ /**
81
+ * Splits a selector list on its top-level commas only. A comma inside
82
+ * `:has()`, `:is()`, `:not()`, `:where()`, an attribute selector or a string
83
+ * belongs to that one selector — splitting there scoped the second half of
84
+ * `:has(a, b)` as if it were a selector of its own, so it never matched.
85
+ */
86
+ const splitSelectorList = (selectors: string): string[] => {
87
+ const parts: string[] = [];
88
+ let depth = 0;
89
+ let quote: string | null = null;
90
+ let start = 0;
91
+
92
+ for (let i = 0; i < selectors.length; i++) {
93
+ const char = selectors[i];
94
+
95
+ if (quote) {
96
+ if (char === '\\') i += 1;
97
+ else if (char === quote) quote = null;
98
+ continue;
99
+ }
100
+
101
+ if (char === '"' || char === "'") quote = char;
102
+ else if (char === '(' || char === '[') depth += 1;
103
+ else if (char === ')' || char === ']') depth = Math.max(0, depth - 1);
104
+ else if (char === ',' && depth === 0) {
105
+ parts.push(selectors.slice(start, i));
106
+ start = i + 1;
107
+ }
108
+ }
109
+ parts.push(selectors.slice(start));
110
+
111
+ return parts;
112
+ };
113
+
80
114
  const scopeSelectorList = (selectors: string, scope: string): string =>
81
- selectors
82
- .split(',')
115
+ splitSelectorList(selectors)
83
116
  .map(selector => selector.trim())
84
117
  .filter(Boolean)
85
118
  .map(selector =>