@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 +16 -0
- package/package.json +4 -4
- package/src/__tests__/account-slot-coverage.test.ts +1 -0
- package/src/__tests__/custom-css.test.ts +12 -0
- package/src/__tests__/route-slug.test.ts +7 -0
- package/src/blocks/__tests__/group-order-items.test.ts +87 -0
- package/src/blocks/__tests__/order-detail-return.test.tsx +208 -0
- package/src/blocks/__tests__/order-line-actions.test.tsx +54 -0
- package/src/blocks/order-detail-block.tsx +294 -67
- package/src/blocks/order-line-actions.ts +45 -0
- package/src/blocks/product-block-utils.tsx +49 -13
- package/src/chrome/placeholder-slugs.ts +4 -0
- package/src/chrome/route-slug.ts +7 -3
- package/src/utils/custom-css.ts +35 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
{
|
|
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,
|
|
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,
|
|
291
|
-
* item-status, item-tracking, item-return-code,
|
|
292
|
-
*
|
|
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
|
-
{
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
<span
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
316
|
-
pk === undefined || pk === null
|
|
317
|
-
|
|
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
|
|
339
|
+
return;
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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',
|
package/src/chrome/route-slug.ts
CHANGED
|
@@ -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
|
|
23
|
-
//
|
|
24
|
-
|
|
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,
|
package/src/utils/custom-css.ts
CHANGED
|
@@ -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 =>
|