@akinon/pz-theme 2.0.157-rc.13 → 2.0.158-rc.14

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,14 +1,6 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.157-rc.13
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [ac6cada1]
8
- - @akinon/next@2.0.157-rc.13
9
- - @akinon/theme-contract@2.0.157-rc.13
10
-
11
- ## 2.0.157-rc.12
3
+ ## 2.0.158-rc.14
12
4
 
13
5
  ### Patch Changes
14
6
 
@@ -17,7 +9,6 @@
17
9
  - 17a598da: ZERO-5296: report the storefront default language to the editor
18
10
  - 3b05a407: ZERO-5232: allow richtext settings on custom blocks
19
11
  - ad5240c4: ZERO-5254: add custom render and theme editor support to pz-rate-review
20
- - Updated dependencies [219164a3]
21
12
  - Updated dependencies [86187b1c2]
22
13
  - Updated dependencies [0cf9ea239]
23
14
  - Updated dependencies [97d29893]
@@ -31,6 +22,7 @@
31
22
  - Updated dependencies [7889b08fe]
32
23
  - Updated dependencies [9f8cd3bc5]
33
24
  - Updated dependencies [bfafa3f49]
25
+ - Updated dependencies [ac6cada1]
34
26
  - Updated dependencies [57d7eb305]
35
27
  - Updated dependencies [d99a6a7d5]
36
28
  - Updated dependencies [9db81a714]
@@ -47,9 +39,18 @@
47
39
  - Updated dependencies [b1111d7b1]
48
40
  - Updated dependencies [1595d458e]
49
41
  - Updated dependencies [dca128112]
42
+ - @akinon/next@2.0.158-rc.14
43
+ - @akinon/theme-contract@2.0.158-rc.14
44
+
45
+ ## 2.0.157
46
+
47
+ ### Patch Changes
48
+
49
+ - 3002c2cc: ZERO-5302: The order detail's return and cancel links offer what commerce allows and what a storefront's order-action rules let through (`registerOrderActionRules`: which lines a return may start from, whether an order may be cancelled). With no rules registered, commerce's `is_refundable` and `is_cancellable` flags decide; the block no longer limits returns to delivered lines on its own.
50
+ - Updated dependencies [219164a3]
50
51
  - Updated dependencies [9c778307]
51
- - @akinon/next@2.0.157-rc.12
52
- - @akinon/theme-contract@2.0.157-rc.12
52
+ - @akinon/next@2.0.157
53
+ - @akinon/theme-contract@2.0.157
53
54
 
54
55
  ## 2.0.156
55
56
 
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.157-rc.13",
4
+ "version": "2.0.158-rc.14",
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.157-rc.13",
11
+ "@akinon/next": "2.0.158-rc.14",
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.157-rc.13",
16
+ "@akinon/theme-contract": "2.0.158-rc.14",
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.157-rc.13",
24
+ "@akinon/next": "2.0.158-rc.14",
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",
@@ -1,6 +1,10 @@
1
1
  import React from 'react';
2
2
  import { render } from '@testing-library/react';
3
3
  import OrderDetailBlock from '../order-detail-block';
4
+ import {
5
+ __resetOrderActionRulesForTests,
6
+ registerOrderActionRules
7
+ } from '../../utils/order-action-rules';
4
8
 
5
9
  /**
6
10
  * ZERO-5183 — the order detail is where an easy return starts and where a
@@ -64,6 +68,8 @@ const texts = (root: ParentNode, part: string) =>
64
68
  (e.textContent || '').replace(/\s+/g, ' ').trim()
65
69
  );
66
70
 
71
+ afterEach(() => __resetOrderActionRulesForTests());
72
+
67
73
  beforeEach(() => {
68
74
  order = {
69
75
  id: 42,
@@ -164,7 +170,7 @@ describe('order detail — easy returns', () => {
164
170
  expect(container.querySelector('[data-part="return-link"]')).toBeNull();
165
171
  });
166
172
 
167
- it('links only a delivered line that has no request yet', () => {
173
+ it('links a refundable line that has no request yet', () => {
168
174
  // As commerce answers after a return: the order and its lines stay
169
175
  // refundable while the request is open.
170
176
  const flagged: Record<string, unknown>[] = (
@@ -194,15 +200,74 @@ describe('order detail — easy returns', () => {
194
200
  };
195
201
  expect(returnLink()).toBeNull();
196
202
 
197
- // Commerce calls a shipped line refundable too; it is not returned yet.
203
+ // Commerce calls a shipped line refundable too, and with no rule from
204
+ // the brand that is what decides (ZERO-5302).
198
205
  order = {
199
206
  ...order,
200
207
  orderitem_set: flagged.map((item) =>
201
- item.id === 3
202
- ? { ...item, status: { value: '500', label: 'Gönderildi' } }
203
- : item
208
+ item.id === 3 ? { ...item, status: { value: '500' } } : item
204
209
  )
205
210
  };
211
+ expect(returnLink()).not.toBeNull();
212
+ });
213
+
214
+ it("asks the brand's rules about a line commerce lets go back (ZERO-5302)", () => {
215
+ const asked: string[] = [];
216
+ registerOrderActionRules({
217
+ returnLine: (item) => {
218
+ asked.push(String(item.status?.value));
219
+ return item.status?.value === '550';
220
+ }
221
+ });
222
+ const returnLink = () =>
223
+ render(
224
+ <OrderDetailBlock
225
+ block={blockOf(ALL_ON) as never}
226
+ placeholderId="p1"
227
+ sectionId="s1"
228
+ isDesigner={false}
229
+ />
230
+ ).container.querySelector('[data-part="return-link"]');
231
+ const withLine3 = (status: string) => ({
232
+ ...order,
233
+ orderitem_set: (order?.orderitem_set as Record<string, unknown>[]).map(
234
+ (item) =>
235
+ item.id === 3
236
+ ? { ...item, is_refundable: true, status: { value: status } }
237
+ : { ...item, is_refundable: false }
238
+ )
239
+ });
240
+
241
+ order = withLine3('550');
242
+ expect(returnLink()).not.toBeNull();
243
+
244
+ order = withLine3('500');
206
245
  expect(returnLink()).toBeNull();
246
+ // Only the line commerce lets go back is put to the brand.
247
+ expect(new Set(asked)).toEqual(new Set(['550', '500']));
248
+ });
249
+
250
+ it('withholds the cancel link when the brand switches cancellation off (ZERO-5302)', () => {
251
+ order = { ...order, is_cancellable: true };
252
+ const cancelLink = () =>
253
+ render(
254
+ <OrderDetailBlock
255
+ block={
256
+ blockOf({
257
+ ...ALL_ON,
258
+ showCancelLink: { desktop: true },
259
+ cancelLinkLabel: { desktop: 'Cancel' }
260
+ }) as never
261
+ }
262
+ placeholderId="p1"
263
+ sectionId="s1"
264
+ isDesigner={false}
265
+ />
266
+ ).container.querySelector('[data-part="cancel-link"]');
267
+
268
+ expect(cancelLink()).not.toBeNull();
269
+
270
+ registerOrderActionRules({ cancelOrder: () => false });
271
+ expect(cancelLink()).toBeNull();
207
272
  });
208
273
  });
@@ -16,6 +16,14 @@ import {
16
16
  groupOrderItems,
17
17
  readToggle
18
18
  } from './product-block-utils';
19
+ import {
20
+ allowsLineReturn,
21
+ allowsOrderCancel,
22
+ type OrderActionLine,
23
+ type OrderActionOrder,
24
+ type OrderActionRules,
25
+ useOrderActionRules
26
+ } from '../utils/order-action-rules';
19
27
 
20
28
  type OrderItemLike = {
21
29
  id?: number;
@@ -118,31 +126,39 @@ const firstString = (
118
126
  const hasOpenRequest = (request: unknown): boolean =>
119
127
  Array.isArray(request) ? request.length > 0 : Boolean(request);
120
128
 
121
- /** Commerce's "Teslim edildi". */
122
- const DELIVERED = '550';
123
-
124
129
  /**
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.
130
+ * Whether the order still has a line to return: one commerce flags
131
+ * refundable, with no request open on it, that the brand's rules let a return
132
+ * start from (`registerOrderActionRules`: from which statuses, for how long
133
+ * after the order — ZERO-5302). Commerce keeps `is_refundable` true on the
134
+ * order and the line while a return request is open, so the order flag alone
135
+ * would offer a return again after the shopper has asked. Lines that carry no
136
+ * flag at all (an older payload) go by the order's flag, still through the
137
+ * brand's rules.
131
138
  */
132
- const hasReturnableLine = (order: OrderLike): boolean => {
139
+ const hasReturnableLine = (
140
+ order: OrderLike,
141
+ rules: OrderActionRules
142
+ ): boolean => {
133
143
  if (!order.is_refundable) return false;
134
144
 
135
145
  const lines = order.orderitem_set ?? [];
146
+ const allows = (item: OrderItemLike | null | undefined) =>
147
+ allowsLineReturn(
148
+ rules,
149
+ (item ?? {}) as OrderActionLine,
150
+ order as OrderActionOrder
151
+ );
136
152
 
137
153
  if (!lines.some((item) => item && 'is_refundable' in item)) {
138
- return String(order.status?.value ?? '') === DELIVERED;
154
+ return lines.length === 0 ? allows(null) : lines.some(allows);
139
155
  }
140
156
 
141
157
  return lines.some(
142
158
  (item) =>
143
- String(item?.status?.value ?? '') === DELIVERED &&
144
159
  item?.is_refundable &&
145
- !hasOpenRequest(item.active_cancellation_request)
160
+ !hasOpenRequest(item.active_cancellation_request) &&
161
+ allows(item)
146
162
  );
147
163
  };
148
164
 
@@ -373,9 +389,9 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
373
389
  * order key is read from the URL; the editor owns which sections are exposed,
374
390
  * every caption and the styling.
375
391
  * A designer can mark one child as the row layout for the order lines; without
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.
392
+ * one the built-in line renders.
393
+ * The cancel and return links offer what commerce allows and the brand's
394
+ * `registerOrderActionRules` lets through (ZERO-5302).
379
395
  * Properties: numberLabel, numberTag, dateLabel, headerTotalLabel,
380
396
  * showInvoiceLink, invoiceLinkLabel, showCancelLink, cancelLinkLabel,
381
397
  * showReturnLink, returnLinkLabel, cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
@@ -591,6 +607,8 @@ const OrderDetailBlock = ({
591
607
  // Not gated on the session: brands expose the same endpoint for anonymous
592
608
  // order tracking. Without a key there is nothing to ask for at all.
593
609
  const { data, isLoading } = useGetOrderQuery(orderKey, { skip: !orderKey });
610
+ // The brand's say over the return and cancel links (ZERO-5302).
611
+ const actionRules = useOrderActionRules();
594
612
 
595
613
  const liveOrder = (data ?? null) as OrderLike | null;
596
614
  // See DESIGNER_SAMPLE_ORDER's own comment: the preview route's order id is
@@ -624,11 +642,16 @@ const OrderDetailBlock = ({
624
642
  const money = (value: unknown) =>
625
643
  formatMoney(value, { locale, currency, ...moneyStyle });
626
644
 
627
- // The sample order is not a delivered one; it shows the link for styling.
645
+ // The sample order shows both links for styling, whatever the brand's
646
+ // rules would make of it.
628
647
  const offersReturn =
629
648
  order === DESIGNER_SAMPLE_ORDER
630
649
  ? Boolean(order.is_refundable)
631
- : hasReturnableLine(order);
650
+ : hasReturnableLine(order, actionRules);
651
+ const offersCancel =
652
+ Boolean(order.is_cancellable) &&
653
+ (order === DESIGNER_SAMPLE_ORDER ||
654
+ allowsOrderCancel(actionRules, order as OrderActionOrder));
632
655
 
633
656
  // Navigating inside the designer iframe would drop the author out of the page
634
657
  // they are editing, so every link is inert there.
@@ -833,7 +856,7 @@ const OrderDetailBlock = ({
833
856
  → {invoiceLinkLabel}
834
857
  </a>
835
858
  )}
836
- {showCancelLink && order.is_cancellable && cancelHref && (
859
+ {showCancelLink && offersCancel && cancelHref && (
837
860
  <NextLink
838
861
  href={isDesigner ? '#' : cancelHref}
839
862
  onClick={guard}
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import { act, render, screen } from '@testing-library/react';
3
+
4
+ import {
5
+ __resetOrderActionRulesForTests,
6
+ allowsLineReturn,
7
+ allowsOrderCancel,
8
+ registerOrderActionRules,
9
+ useOrderActionRules
10
+ } from '../order-action-rules';
11
+
12
+ /**
13
+ * The brand's say over the order actions a storefront offers (ZERO-5302):
14
+ * asked only about what commerce already allows; with no rule, commerce's
15
+ * flags decide.
16
+ */
17
+
18
+ const line = (status: string) => ({ id: 1, status: { value: status } });
19
+ const order = { id: 7, number: 'O-7' };
20
+
21
+ afterEach(() => __resetOrderActionRulesForTests());
22
+
23
+ describe('order action rules', () => {
24
+ it('leaves the decision to commerce when no brand registered rules', () => {
25
+ expect(allowsLineReturn({}, line('500'), order)).toBe(true);
26
+ expect(allowsOrderCancel({}, order)).toBe(true);
27
+ });
28
+
29
+ it('lets a brand narrow returns and cancellations', () => {
30
+ const rules = {
31
+ returnLine: (item: { status?: { value?: string } | null }) =>
32
+ item.status?.value === '550',
33
+ cancelOrder: () => false
34
+ };
35
+
36
+ expect(allowsLineReturn(rules, line('550'), order)).toBe(true);
37
+ expect(allowsLineReturn(rules, line('500'), order)).toBe(false);
38
+ expect(allowsOrderCancel(rules, order)).toBe(false);
39
+ });
40
+
41
+ it('gives a rule that throws no say', () => {
42
+ const rules = {
43
+ returnLine: () => {
44
+ throw new Error('brand bug');
45
+ },
46
+ cancelOrder: () => {
47
+ throw new Error('brand bug');
48
+ }
49
+ };
50
+
51
+ expect(allowsLineReturn(rules, line('550'), order)).toBe(true);
52
+ expect(allowsOrderCancel(rules, order)).toBe(true);
53
+ });
54
+
55
+ it('renders a reader again when the rules are registered or dropped', () => {
56
+ const Reader = () => {
57
+ const rules = useOrderActionRules();
58
+ return (
59
+ <span data-testid="reader">
60
+ {allowsLineReturn(rules, line('500'), order) ? 'offers' : 'withholds'}
61
+ </span>
62
+ );
63
+ };
64
+ render(<Reader />);
65
+ expect(screen.getByTestId('reader')).toHaveTextContent('offers');
66
+
67
+ let unregister = () => {};
68
+ act(() => {
69
+ unregister = registerOrderActionRules({ returnLine: () => false });
70
+ });
71
+ expect(screen.getByTestId('reader')).toHaveTextContent('withholds');
72
+
73
+ act(() => unregister());
74
+ expect(screen.getByTestId('reader')).toHaveTextContent('offers');
75
+ });
76
+
77
+ it('keeps rules registered later when an older registration unregisters', () => {
78
+ const first = registerOrderActionRules({ returnLine: () => false });
79
+ const second = { returnLine: () => true };
80
+ registerOrderActionRules(second);
81
+
82
+ first();
83
+
84
+ expect(allowsLineReturn(second, line('500'), order)).toBe(true);
85
+ const Reader = () => {
86
+ const rules = useOrderActionRules();
87
+ return (
88
+ <span data-testid="current">
89
+ {rules === second ? 'second' : 'other'}
90
+ </span>
91
+ );
92
+ };
93
+ render(<Reader />);
94
+ expect(screen.getByTestId('current')).toHaveTextContent('second');
95
+ });
96
+ });
@@ -0,0 +1,115 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * Brand hook for the order actions a storefront offers (ZERO-5302).
5
+ *
6
+ * Commerce says what it allows: a line it will take back is flagged
7
+ * `is_refundable`, an order it will cancel `is_cancellable`. Whether the
8
+ * storefront offers that return or that cancellation is a brand decision on
9
+ * top — from which statuses, for how long after the order, whether the action
10
+ * is switched on at all — so the order blocks never learn it. A brand
11
+ * registers its rules from its own code (the pattern of the search-term
12
+ * transform), and the blocks ask them only about what commerce already
13
+ * allows. No registration, or a rule left out, and commerce's flags decide.
14
+ * A rule that throws has no say: a brand bug must not take the page down.
15
+ *
16
+ * Rules may be registered again when what they depend on arrives (a brand
17
+ * reading its settings from a widget, say); every block reading them through
18
+ * `useOrderActionRules` renders again.
19
+ */
20
+
21
+ export interface OrderActionLine {
22
+ id?: number | string;
23
+ status?: { value?: string; label?: string } | null;
24
+ created_date?: string | null;
25
+ is_refundable?: boolean;
26
+ is_cancellable?: boolean;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export interface OrderActionOrder {
31
+ id?: number | string;
32
+ number?: string;
33
+ created_date?: string | null;
34
+ status?: { value?: string; label?: string } | null;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ export interface OrderActionRules {
39
+ /**
40
+ * Whether a return may start from this line. Asked only about a line
41
+ * commerce flags refundable and that has no request open on it.
42
+ */
43
+ returnLine?: (line: OrderActionLine, order: OrderActionOrder) => boolean;
44
+ /**
45
+ * Whether the order may be cancelled from the storefront. Asked only about
46
+ * an order commerce flags cancellable.
47
+ */
48
+ cancelOrder?: (order: OrderActionOrder) => boolean;
49
+ }
50
+
51
+ const NO_RULES: OrderActionRules = {};
52
+
53
+ let current: OrderActionRules = NO_RULES;
54
+ const listeners = new Set<() => void>();
55
+
56
+ const notify = () => listeners.forEach((listener) => listener());
57
+
58
+ /** Register the brand's rules. Returns an unregister function. */
59
+ export const registerOrderActionRules = (
60
+ rules: OrderActionRules
61
+ ): (() => void) => {
62
+ current = rules;
63
+ notify();
64
+ return () => {
65
+ if (current !== rules) return;
66
+ current = NO_RULES;
67
+ notify();
68
+ };
69
+ };
70
+
71
+ const subscribe = (listener: () => void) => {
72
+ listeners.add(listener);
73
+ return () => {
74
+ listeners.delete(listener);
75
+ };
76
+ };
77
+
78
+ const getSnapshot = () => current;
79
+ const getServerSnapshot = () => NO_RULES;
80
+
81
+ /** The rules in force; the caller renders again when they are replaced. */
82
+ export const useOrderActionRules = (): OrderActionRules =>
83
+ useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
84
+
85
+ /** Whether the brand lets a return start from a line commerce allows. */
86
+ export const allowsLineReturn = (
87
+ rules: OrderActionRules,
88
+ line: OrderActionLine,
89
+ order: OrderActionOrder
90
+ ): boolean => {
91
+ if (!rules.returnLine) return true;
92
+ try {
93
+ return rules.returnLine(line, order) !== false;
94
+ } catch {
95
+ return true;
96
+ }
97
+ };
98
+
99
+ /** Whether the brand lets an order commerce allows be cancelled. */
100
+ export const allowsOrderCancel = (
101
+ rules: OrderActionRules,
102
+ order: OrderActionOrder
103
+ ): boolean => {
104
+ if (!rules.cancelOrder) return true;
105
+ try {
106
+ return rules.cancelOrder(order) !== false;
107
+ } catch {
108
+ return true;
109
+ }
110
+ };
111
+
112
+ /** Test-only: drop the registered rules between cases. */
113
+ export const __resetOrderActionRulesForTests = (): void => {
114
+ current = NO_RULES;
115
+ };