@akinon/pz-theme 2.0.154-rc.6 → 2.0.154

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.
@@ -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,
@@ -303,26 +303,6 @@ attached". The editor's **Use preview** toggle turns it off while the editor
303
303
  stays open, so the canvas behaves like the live site: whatever you held open
304
304
  closes and links navigate again.
305
305
 
306
- ## Reading the row a block is placed in
307
-
308
- A block dropped into a list's row layout (the child a designer marked as the
309
- row template of an order list, a product grid, an address list, …) renders once
310
- per row. The product lists open the product scope `useThemeProduct()` reads;
311
- every list also opens a row scope that carries the row itself:
312
-
313
- ```ts
314
- import { useThemeRowScope } from '@akinon/pz-theme/src/blocks/row-scope';
315
-
316
- const scope = useThemeRowScope();
317
- ```
318
-
319
- Inside a row it returns `{ listType, row, index }`, for example
320
- `listType: 'order-list'` with the order's `number` and `items` on `row`; anywhere
321
- else it returns `null`. `row` is the same record the row's bindings resolve
322
- against, so whatever a text block can bind to (`item.number`), code can read.
323
- Import it from that deep path for the same reason as `useThemePageContext`: the
324
- package root is not importable from a client module.
325
-
326
306
  ## Reporting what happened
327
307
 
328
308
  A custom unit can report a moment — a strip was seen, a lookbook was opened, a
@@ -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 =>
@@ -1,118 +0,0 @@
1
- import React from 'react';
2
- import { render, screen } from '@testing-library/react';
3
- import { ItemTemplateList } from '../blocks/item-template';
4
- import { useThemeProduct } from '../blocks/product-block-utils';
5
- import { useThemeRowScope } from '../blocks/row-scope';
6
- import blockRendererRegistry from '../blocks/block-renderer-registry';
7
- import { resetCustomCatalog } from '../custom/catalog';
8
- import { resetCustomCollection } from '../custom/collect';
9
- import { isCustomThemeType, registerCustomBlocks } from '../custom';
10
- import type { Block } from '../theme-block';
11
-
12
- const READER = 'custom:acme:row-reader';
13
-
14
- function RowReader() {
15
- const scope = useThemeRowScope();
16
- const product = useThemeProduct();
17
-
18
- return (
19
- <span
20
- data-testid="reader"
21
- data-list={scope?.listType ?? ''}
22
- data-index={scope?.index ?? ''}
23
- data-number={String(scope?.row.number ?? '')}
24
- data-product={String(product?.pk ?? '')}
25
- />
26
- );
27
- }
28
-
29
- const template = (): Block =>
30
- ({
31
- id: 'tpl',
32
- type: 'group',
33
- label: 'Row',
34
- order: 0,
35
- hidden: false,
36
- value: '',
37
- properties: { role: { desktop: 'item-template' } },
38
- styles: {},
39
- blocks: [
40
- {
41
- id: 'tpl-reader',
42
- type: READER,
43
- label: 'Reader',
44
- order: 0,
45
- hidden: false,
46
- value: '',
47
- properties: {},
48
- styles: {},
49
- blocks: []
50
- }
51
- ]
52
- } as Block);
53
-
54
- const list = (type: string): Block =>
55
- ({
56
- id: 'list',
57
- type,
58
- label: 'List',
59
- order: 0,
60
- hidden: false,
61
- value: '',
62
- properties: {},
63
- styles: {},
64
- blocks: [template()]
65
- } as Block);
66
-
67
- const renderRows = (type: string, items: Record<string, unknown>[]) =>
68
- render(
69
- <ItemTemplateList
70
- block={list(type)}
71
- items={items}
72
- placeholderId="p1"
73
- sectionId="s1"
74
- />
75
- );
76
-
77
- beforeAll(() => {
78
- registerCustomBlocks('acme', { 'row-reader': RowReader });
79
- });
80
-
81
- afterAll(() => {
82
- const map = (
83
- blockRendererRegistry as unknown as { renderers: Map<string, unknown> }
84
- ).renderers;
85
- Array.from(map.keys())
86
- .filter(isCustomThemeType)
87
- .forEach((key) => map.delete(key));
88
- resetCustomCatalog();
89
- resetCustomCollection();
90
- });
91
-
92
- describe('the row scope', () => {
93
- it('hands every repeated row to the blocks inside it, on any list', () => {
94
- renderRows('order-list', [{ number: 'ORD-1' }, { number: 'ORD-2' }]);
95
-
96
- const readers = screen.getAllByTestId('reader');
97
- expect(readers).toHaveLength(2);
98
- expect(readers[0]).toHaveAttribute('data-list', 'order-list');
99
- expect(readers[0]).toHaveAttribute('data-index', '0');
100
- expect(readers[0]).toHaveAttribute('data-number', 'ORD-1');
101
- expect(readers[1]).toHaveAttribute('data-number', 'ORD-2');
102
- expect(readers[0]).toHaveAttribute('data-product', '');
103
- });
104
-
105
- it('keeps the product scope on product lists next to the row scope', () => {
106
- renderRows('product-grid', [{ pk: 7, name: 'Sneaker' }]);
107
-
108
- const reader = screen.getByTestId('reader');
109
- expect(reader).toHaveAttribute('data-list', 'product-grid');
110
- expect(reader).toHaveAttribute('data-product', '7');
111
- });
112
-
113
- it('is closed outside a repeated row', () => {
114
- render(<RowReader />);
115
-
116
- expect(screen.getByTestId('reader')).toHaveAttribute('data-list', '');
117
- });
118
- });
@@ -1,54 +0,0 @@
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
- });
@@ -1,112 +0,0 @@
1
- import React from 'react';
2
- import { render } from '@testing-library/react';
3
- import OrderListBlock from '../order-list-block';
4
-
5
- const captured: { items: Record<string, unknown>[] } = { items: [] };
6
-
7
- jest.mock('../item-template', () => ({
8
- __esModule: true,
9
- hasItemTemplate: () => true,
10
- ItemTemplateList: ({ items }: { items: Record<string, unknown>[] }) => {
11
- captured.items = items;
12
- return <div data-testid="item-template-list" />;
13
- }
14
- }));
15
-
16
- jest.mock('next-auth/react', () => ({
17
- useSession: () => ({ data: null, status: 'authenticated' })
18
- }));
19
-
20
- jest.mock('@akinon/next/hooks', () => ({
21
- __esModule: true,
22
- useLocalization: () => ({ locale: 'tr', currency: 'try' })
23
- }));
24
-
25
- jest.mock('@akinon/next/redux/hooks', () => ({
26
- __esModule: true,
27
- useAppSelector: () => undefined,
28
- useAppDispatch: () => jest.fn()
29
- }));
30
-
31
- jest.mock('@akinon/next/data/client/account', () => ({
32
- __esModule: true,
33
- useGetOrdersQuery: () => ({
34
- data: {
35
- count: 1,
36
- results: [
37
- {
38
- id: 42,
39
- number: 'ORD-42',
40
- amount: '100.00',
41
- created_date: '2026-09-01T10:00:00Z',
42
- status: { value: '550', label: 'Delivered' },
43
- orderitem_set: [
44
- {
45
- id: 7,
46
- status: { value: 550, label: 'Delivered' },
47
- product: {
48
- sku: 'SKU-1',
49
- base_code: 'BASE-1',
50
- name: 'Sneaker',
51
- image: '/s.jpg'
52
- }
53
- },
54
- {
55
- status: { value: '400', label: 'Approved' },
56
- product: { name: 'Boot' }
57
- }
58
- ]
59
- }
60
- ]
61
- },
62
- isLoading: false
63
- })
64
- }));
65
-
66
- describe('order list rows', () => {
67
- it('publishes the order items with their product codes and status code', () => {
68
- render(
69
- <OrderListBlock
70
- block={{
71
- id: 'order-list-1',
72
- type: 'order-list',
73
- properties: {},
74
- styles: {},
75
- blocks: []
76
- }}
77
- placeholderId="p1"
78
- sectionId="s1"
79
- />
80
- );
81
-
82
- expect(captured.items).toHaveLength(1);
83
- expect(captured.items[0]).toMatchObject({
84
- id: 42,
85
- number: 'ORD-42',
86
- createdDate: '2026-09-01T10:00:00Z'
87
- });
88
- expect(captured.items[0].items).toEqual([
89
- {
90
- id: 7,
91
- name: 'Sneaker',
92
- image: '/s.jpg',
93
- status: 'Delivered',
94
- statusValue: '550',
95
- product: {
96
- sku: 'SKU-1',
97
- base_code: 'BASE-1',
98
- name: 'Sneaker',
99
- image: '/s.jpg'
100
- }
101
- },
102
- {
103
- id: 1,
104
- name: 'Boot',
105
- image: '',
106
- status: 'Approved',
107
- statusValue: '400',
108
- product: { sku: '', base_code: '', name: 'Boot', image: '' }
109
- }
110
- ]);
111
- });
112
- });
@@ -1,45 +0,0 @@
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
- };
@@ -1,35 +0,0 @@
1
- 'use client';
2
-
3
- import React from 'react';
4
-
5
- export interface ThemeRowScopeValue {
6
- listType: string;
7
- row: Record<string, unknown>;
8
- index: number;
9
- }
10
-
11
- const ThemeRowScopeContext = React.createContext<ThemeRowScopeValue | null>(
12
- null
13
- );
14
-
15
- export const ThemeRowScopeProvider = ({
16
- listType,
17
- row,
18
- index,
19
- children
20
- }: ThemeRowScopeValue & { children: React.ReactNode }) => {
21
- const value = React.useMemo(
22
- () => ({ listType, row, index }),
23
- [listType, row, index]
24
- );
25
-
26
- return (
27
- <ThemeRowScopeContext.Provider value={value}>
28
- {children}
29
- </ThemeRowScopeContext.Provider>
30
- );
31
- };
32
-
33
- /** Non-null while a repeated row of any list renders; null everywhere else. */
34
- export const useThemeRowScope = (): ThemeRowScopeValue | null =>
35
- React.useContext(ThemeRowScopeContext);