@akinon/pz-theme 2.0.157 → 2.0.158-beta.0

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,10 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
+ ## 2.0.158-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.158-beta.0
9
+ - @akinon/theme-contract@2.0.158-beta.0
10
+
3
11
  ## 2.0.157
4
12
 
5
13
  ### Patch Changes
6
14
 
15
+ - 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.
7
16
  - Updated dependencies [219164a3]
17
+ - Updated dependencies [9c778307]
8
18
  - @akinon/next@2.0.157
9
19
  - @akinon/theme-contract@2.0.157
10
20
 
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",
4
+ "version": "2.0.158-beta.0",
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",
11
+ "@akinon/next": "2.0.158-beta.0",
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",
16
+ "@akinon/theme-contract": "2.0.158-beta.0",
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",
24
+ "@akinon/next": "2.0.158-beta.0",
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
  });
@@ -15,6 +15,14 @@ import {
15
15
  groupOrderItems,
16
16
  readToggle
17
17
  } from './product-block-utils';
18
+ import {
19
+ allowsLineReturn,
20
+ allowsOrderCancel,
21
+ type OrderActionLine,
22
+ type OrderActionOrder,
23
+ type OrderActionRules,
24
+ useOrderActionRules
25
+ } from '../utils/order-action-rules';
18
26
 
19
27
  interface OrderItemLike {
20
28
  id?: number;
@@ -117,31 +125,39 @@ const firstString = (
117
125
  const hasOpenRequest = (request: unknown): boolean =>
118
126
  Array.isArray(request) ? request.length > 0 : Boolean(request);
119
127
 
120
- /** Commerce's "Teslim edildi". */
121
- const DELIVERED = '550';
122
-
123
128
  /**
124
- * Whether the order still has a line to return: delivered, refundable, with
125
- * no request open on it. Commerce also marks a shipped line refundable, and
126
- * keeps `is_refundable` true on the order and the line while a return request
127
- * is open, so the order flag alone would offer a return before the parcel
128
- * arrives and again after the shopper has asked. Lines that carry no flag at
129
- * all (an older payload) leave the decision to the order's flag and status.
129
+ * Whether the order still has a line to return: one commerce flags
130
+ * refundable, with no request open on it, that the brand's rules let a return
131
+ * start from (`registerOrderActionRules`: from which statuses, for how long
132
+ * after the order — ZERO-5302). Commerce keeps `is_refundable` true on the
133
+ * order and the line while a return request is open, so the order flag alone
134
+ * would offer a return again after the shopper has asked. Lines that carry no
135
+ * flag at all (an older payload) go by the order's flag, still through the
136
+ * brand's rules.
130
137
  */
131
- const hasReturnableLine = (order: OrderLike): boolean => {
138
+ const hasReturnableLine = (
139
+ order: OrderLike,
140
+ rules: OrderActionRules
141
+ ): boolean => {
132
142
  if (!order.is_refundable) return false;
133
143
 
134
144
  const lines = order.orderitem_set ?? [];
145
+ const allows = (item: OrderItemLike | null | undefined) =>
146
+ allowsLineReturn(
147
+ rules,
148
+ (item ?? {}) as OrderActionLine,
149
+ order as OrderActionOrder
150
+ );
135
151
 
136
152
  if (!lines.some((item) => item && 'is_refundable' in item)) {
137
- return String(order.status?.value ?? '') === DELIVERED;
153
+ return lines.length === 0 ? allows(null) : lines.some(allows);
138
154
  }
139
155
 
140
156
  return lines.some(
141
157
  (item) =>
142
- String(item?.status?.value ?? '') === DELIVERED &&
143
158
  item?.is_refundable &&
144
- !hasOpenRequest(item.active_cancellation_request)
159
+ !hasOpenRequest(item.active_cancellation_request) &&
160
+ allows(item)
145
161
  );
146
162
  };
147
163
 
@@ -373,6 +389,8 @@ const formatAddress = (address: AddressLike | null | undefined): string[] => {
373
389
  * every caption and the styling.
374
390
  * A designer can mark one child as the row layout for the order lines; without
375
391
  * one the built-in line renders.
392
+ * The cancel and return links offer what commerce allows and the brand's
393
+ * `registerOrderActionRules` lets through (ZERO-5302).
376
394
  * Properties: numberLabel, numberTag, dateLabel, headerTotalLabel,
377
395
  * showInvoiceLink, invoiceLinkLabel, showCancelLink, cancelLinkLabel,
378
396
  * showReturnLink, returnLinkLabel, cancelHrefBase, showShipmentStatus, showItems, showImage, imageWidth,
@@ -588,6 +606,8 @@ const OrderDetailBlock = ({
588
606
  // Not gated on the session: brands expose the same endpoint for anonymous
589
607
  // order tracking. Without a key there is nothing to ask for at all.
590
608
  const { data, isLoading } = useGetOrderQuery(orderKey, { skip: !orderKey });
609
+ // The brand's say over the return and cancel links (ZERO-5302).
610
+ const actionRules = useOrderActionRules();
591
611
 
592
612
  const liveOrder = (data ?? null) as OrderLike | null;
593
613
  // See DESIGNER_SAMPLE_ORDER's own comment: the preview route's order id is
@@ -621,11 +641,16 @@ const OrderDetailBlock = ({
621
641
  const money = (value: unknown) =>
622
642
  formatMoney(value, { locale, currency, ...moneyStyle });
623
643
 
624
- // The sample order is not a delivered one; it shows the link for styling.
644
+ // The sample order shows both links for styling, whatever the brand's
645
+ // rules would make of it.
625
646
  const offersReturn =
626
647
  order === DESIGNER_SAMPLE_ORDER
627
648
  ? Boolean(order.is_refundable)
628
- : hasReturnableLine(order);
649
+ : hasReturnableLine(order, actionRules);
650
+ const offersCancel =
651
+ Boolean(order.is_cancellable) &&
652
+ (order === DESIGNER_SAMPLE_ORDER ||
653
+ allowsOrderCancel(actionRules, order as OrderActionOrder));
629
654
 
630
655
  // Navigating inside the designer iframe would drop the author out of the page
631
656
  // they are editing, so every link is inert there.
@@ -830,7 +855,7 @@ const OrderDetailBlock = ({
830
855
  → {invoiceLinkLabel}
831
856
  </a>
832
857
  )}
833
- {showCancelLink && order.is_cancellable && cancelHref && (
858
+ {showCancelLink && offersCancel && cancelHref && (
834
859
  <NextLink
835
860
  href={isDesigner ? '#' : cancelHref}
836
861
  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
+ };