@akinon/pz-theme 2.0.154-rc.6 → 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,13 @@
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
+
3
11
  ## 2.0.154-rc.6
4
12
 
5
13
  ### 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.6",
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.6",
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.6",
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.6",
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
+ });
@@ -27,6 +27,7 @@ type OrderItemLike = {
27
27
  tracking_number?: string;
28
28
  estimated_delivery_date?: string;
29
29
  price_currency?: { value?: string; label?: string };
30
+ is_refundable?: boolean;
30
31
  active_cancellation_request?: unknown;
31
32
  product?: {
32
33
  pk?: number;
@@ -66,6 +67,7 @@ interface OrderLike {
66
67
  orderitem_set?: OrderItemLike[];
67
68
  discountitem_set?: DiscountItemLike[];
68
69
  is_cancellable?: boolean;
70
+ is_refundable?: boolean;
69
71
  e_archive_url?: string | null;
70
72
  shipping_address?: AddressLike | null;
71
73
  billing_address?: AddressLike | null;
@@ -113,6 +115,37 @@ const firstString = (
113
115
  return '';
114
116
  };
115
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
+
116
149
  /** Positive integers only; anything else falls back to the shipped size. */
117
150
  const readSize = (value: unknown, fallback: number): number => {
118
151
  const numeric = Math.round(Number(value));
@@ -146,6 +179,7 @@ const DESIGNER_SAMPLE_ORDER: OrderLike = {
146
179
  status: { value: '10', label: 'Approved' },
147
180
  currency: { value: 'try', label: 'TRY' },
148
181
  is_cancellable: true,
182
+ is_refundable: true,
149
183
  e_archive_url: '#',
150
184
  installment_count: 3,
151
185
  orderitem_set: [
@@ -159,7 +193,16 @@ const DESIGNER_SAMPLE_ORDER: OrderLike = {
159
193
  tracking_number: '1Z999AA10123456784',
160
194
  tracking_url: '#',
161
195
  active_cancellation_request: [
162
- { 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
+ }
163
206
  ],
164
207
  product: {
165
208
  pk: 1,
@@ -250,6 +293,63 @@ const resolveReturnCode = (value: unknown): string => {
250
293
  return typeof code === 'string' ? code : '';
251
294
  };
252
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
+
253
353
  const formatAddress = (address: AddressLike | null | undefined): string[] => {
254
354
  if (!address) return [];
255
355
  const name = [address.first_name, address.last_name]
@@ -278,11 +378,13 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
278
378
  * line's `item-actions` part.
279
379
  * Properties: numberLabel, numberTag, dateLabel, headerTotalLabel,
280
380
  * showInvoiceLink, invoiceLinkLabel, showCancelLink, cancelLinkLabel,
281
- * cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
381
+ * showReturnLink, returnLinkLabel, cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
282
382
  * imageHeight, showQuantity, quantityLabel, showColor,
283
383
  * colorLabel, colorAttributeKey, showSize, sizeLabel, sizeAttributeKey,
284
384
  * showLineTracking, lineTrackingLabel, showReturnCode,
285
- * returnCodeLabel, showShippingAddress, shippingAddressLabel,
385
+ * returnCodeLabel, showReturnMethod, returnMethodLabel,
386
+ * appointmentReturnLabel, standardReturnLabel, showReturnCarrier,
387
+ * returnCarrierLabel, showPickupDate, pickupDateLabel, showShippingAddress, shippingAddressLabel,
286
388
  * showBillingAddress, billingAddressLabel, showTotals, showPieceCount,
287
389
  * pieceCountLabel, showDiscountItems, showInstallmentInfo,
288
390
  * installmentInfoLabel, subtotalLabel, discountLabel, shippingLabel,
@@ -290,9 +392,10 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
290
392
  * showPaymentInfo, paymentInfoLabel, decimals, currencyDisplay,
291
393
  * currencyText, currencyPosition.
292
394
  * Parts: detail, header, number, date, header-total, status,
293
- * invoice-link, cancel-link, list, item, image, name, quantity, color, size,
294
- * item-status, item-tracking, item-return-code, item-actions, price, shipment,
295
- * 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,
296
399
  * total-label, total-value, grand-total, payment-info, payment-info-label.
297
400
  */
298
401
  const OrderDetailBlock = ({
@@ -402,6 +505,38 @@ const OrderDetailBlock = ({
402
505
  false
403
506
  );
404
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:');
405
540
 
406
541
  const showShippingAddress = readToggle(
407
542
  block.properties?.showShippingAddress,
@@ -489,6 +624,12 @@ const OrderDetailBlock = ({
489
624
  const money = (value: unknown) =>
490
625
  formatMoney(value, { locale, currency, ...moneyStyle });
491
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
+
492
633
  // Navigating inside the designer iframe would drop the author out of the page
493
634
  // they are editing, so every link is inert there.
494
635
  const guard = (event: React.MouseEvent) => {
@@ -667,7 +808,7 @@ const OrderDetailBlock = ({
667
808
  {headerTotalLabel} {money(order.amount)}
668
809
  </span>
669
810
  )}
670
- {(showInvoiceLink || showCancelLink) && (
811
+ {(showInvoiceLink || showCancelLink || showReturnLink) && (
671
812
  <div
672
813
  style={{
673
814
  display: 'flex',
@@ -707,6 +848,21 @@ const OrderDetailBlock = ({
707
848
  → {cancelLinkLabel}
708
849
  </NextLink>
709
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
+ )}
710
866
  </div>
711
867
  )}
712
868
  </div>
@@ -761,6 +917,22 @@ const OrderDetailBlock = ({
761
917
  const returnCodeValue = showReturnCode
762
918
  ? resolveReturnCode(item.active_cancellation_request)
763
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
+ : '';
764
936
  const lineActions = renderOrderLineActions({
765
937
  order,
766
938
  line,
@@ -768,7 +940,14 @@ const OrderDetailBlock = ({
768
940
  });
769
941
  const showLineMeta =
770
942
  showShipmentStatus &&
771
- Boolean(item.status?.label || trackingValue || returnCodeValue);
943
+ Boolean(
944
+ item.status?.label ||
945
+ trackingValue ||
946
+ returnCodeValue ||
947
+ returnMethodValue ||
948
+ returnCarrierValue ||
949
+ pickupDateValue
950
+ );
772
951
 
773
952
  return (
774
953
  <li
@@ -913,6 +1092,38 @@ const OrderDetailBlock = ({
913
1092
  {item.status.label}
914
1093
  </span>
915
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
+ )}
916
1127
  {showLineMeta && trackingValue && (
917
1128
  <span
918
1129
  data-order-detail-item-tracking=""
@@ -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 =>