@akinon/pz-theme 2.0.39-rc.0 → 2.0.39

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,36 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.39-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea23]
8
- - Updated dependencies [324f97d5]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08f]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a963]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b2]
32
- - @akinon/next@2.0.39-rc.0
33
- - @akinon/theme-contract@2.0.39-rc.0
3
+ ## 2.0.39
4
+
5
+ ### Patch Changes
6
+
7
+ - 115c5602: ZERO-4723: Resolve page-scoped commerce data bindings on every page type
8
+ - Updated dependencies [115c5602]
9
+ - @akinon/theme-contract@2.0.39
10
+ - @akinon/next@2.0.39
34
11
 
35
12
  ## 2.0.38
36
13
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.39-rc.0",
4
+ "version": "2.0.39",
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.39-rc.0",
11
+ "@akinon/next": "2.0.39",
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.39-rc.0",
16
+ "@akinon/theme-contract": "2.0.39",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.39-rc.0",
23
+ "@akinon/next": "2.0.39",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,104 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ import ThemeSection from '../theme-section';
5
+ import { ThemeSettingsProvider } from '../theme-settings-context';
6
+
7
+ /**
8
+ * The unit tests around `resolveBlockBindings` call the resolver directly, so
9
+ * they cannot see the gate that decides whether a section runs the binding pass
10
+ * at all. That gate is what made a correct binding resolve to nothing: it
11
+ * demanded either a section literally typed `custom` or a hand-created
12
+ * page-context data source, neither of which a designer produces by dropping a
13
+ * text block onto the basket page.
14
+ */
15
+
16
+ const basketPageContext = {
17
+ page: { type: 'basket', title: 'Basket', path: '/basket' },
18
+ basket: {
19
+ isEmpty: false,
20
+ itemCount: 1,
21
+ totalQuantity: 2,
22
+ totalAmount: '1299.90',
23
+ currency: 'TRY',
24
+ items: [{ title: 'Spring Sneaker', currency: 'TRY' }]
25
+ }
26
+ };
27
+
28
+ const sectionWithBoundTotal = (overrides: Record<string, unknown> = {}) =>
29
+ ({
30
+ id: 'section-1',
31
+ type: 'default',
32
+ label: 'Basket header',
33
+ order: 0,
34
+ hidden: false,
35
+ styles: {},
36
+ properties: {},
37
+ blocks: [
38
+ {
39
+ id: 'text-1',
40
+ type: 'text',
41
+ label: 'Total',
42
+ value: 'Authored fallback',
43
+ properties: { dataBinding: 'basket.totalAmount' },
44
+ styles: {},
45
+ order: 0,
46
+ hidden: false
47
+ }
48
+ ],
49
+ ...overrides
50
+ } as never);
51
+
52
+ const renderSection = (section: never, pageContext: unknown) =>
53
+ render(
54
+ <ThemeSettingsProvider value={null}>
55
+ <ThemeSection
56
+ section={section}
57
+ placeholderId="basket-page-body"
58
+ currentBreakpoint="desktop"
59
+ isDesigner={false}
60
+ pageContext={pageContext as never}
61
+ />
62
+ </ThemeSettingsProvider>
63
+ );
64
+
65
+ describe('a section on a page that publishes commerce data', () => {
66
+ it('resolves the binding with no data source configured', () => {
67
+ const { container } = renderSection(
68
+ sectionWithBoundTotal(),
69
+ basketPageContext
70
+ );
71
+
72
+ expect(container.textContent).toContain('1,299.90 TL');
73
+ expect(container.textContent).not.toContain('Authored fallback');
74
+ });
75
+
76
+ it('keeps the authored value when the page publishes nothing', () => {
77
+ const { container } = renderSection(sectionWithBoundTotal(), null);
78
+
79
+ expect(container.textContent).toContain('Authored fallback');
80
+ });
81
+
82
+ it('still resolves for a section that declares a page-context source', () => {
83
+ // The path that already worked must keep working.
84
+ const { container } = renderSection(
85
+ sectionWithBoundTotal({
86
+ dataSource: { type: 'page-context', details: {} }
87
+ }),
88
+ basketPageContext
89
+ );
90
+
91
+ expect(container.textContent).toContain('1,299.90 TL');
92
+ });
93
+
94
+ it('falls back to the storefront currency when the basket has none', () => {
95
+ const { container } = renderSection(sectionWithBoundTotal(), {
96
+ ...basketPageContext,
97
+ basket: { ...basketPageContext.basket, currency: '', items: [] }
98
+ });
99
+
100
+ // The storefront mock reports en-us / USD, which is also what proves the
101
+ // locale reaches the formatter rather than the tr-TR module default.
102
+ expect(container.textContent).toContain('1,299.90 USD');
103
+ });
104
+ });
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ import { useThemePageContext } from '../../theme-page-context';
5
+ import { resolveBlockBindings } from '../../utils/iterator-utils';
6
+
7
+ const useGetBasketQuery = jest.fn();
8
+
9
+ jest.mock('@akinon/next/data/client/basket', () => ({
10
+ useGetBasketQuery: (...args: unknown[]) => useGetBasketQuery(...args)
11
+ }));
12
+
13
+ // Imported after the mock so the provider picks it up.
14
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
15
+ const {
16
+ LiveBasketPageContext
17
+ } = require('../live-basket-page-context') as typeof import('../live-basket-page-context');
18
+
19
+ /** Stands in for a composed text block bound to the basket total. */
20
+ const BoundTotal = () => {
21
+ const pageContext = useThemePageContext();
22
+ const block = resolveBlockBindings({
23
+ block: {
24
+ id: 'total',
25
+ type: 'text',
26
+ label: 'Total',
27
+ value: 'Fallback',
28
+ properties: { dataBinding: 'basket.totalAmount' },
29
+ styles: {},
30
+ order: 0,
31
+ hidden: false
32
+ } as never,
33
+ pageContext
34
+ });
35
+
36
+ return <span data-testid="total">{String(block.value)}</span>;
37
+ };
38
+
39
+ const basketWith = (total: string, quantity: number) => ({
40
+ pk: 1,
41
+ total_amount: total,
42
+ total_quantity: quantity,
43
+ total_product_amount: total,
44
+ total_discount_amount: '0.00',
45
+ voucher_code: null,
46
+ discounts: [],
47
+ basketitem_set: [
48
+ {
49
+ pk: 10,
50
+ quantity,
51
+ total_amount: total,
52
+ unit_price: '649.95',
53
+ price: '649.95',
54
+ currency_type: 'TRY',
55
+ product: { name: 'Spring Sneaker', absolute_url: '/p/1' }
56
+ }
57
+ ]
58
+ });
59
+
60
+ describe('LiveBasketPageContext', () => {
61
+ beforeEach(() => useGetBasketQuery.mockReset());
62
+
63
+ it('keeps the authored value until the basket query answers', () => {
64
+ useGetBasketQuery.mockReturnValue({ data: undefined });
65
+
66
+ render(
67
+ <LiveBasketPageContext>
68
+ <BoundTotal />
69
+ </LiveBasketPageContext>
70
+ );
71
+
72
+ // Server and first client render must agree — the provider publishes null
73
+ // rather than a hollow context, so nothing is masked and nothing pops.
74
+ expect(screen.getByTestId('total')).toHaveTextContent('Fallback');
75
+ });
76
+
77
+ it('publishes the live total, formatted as money', () => {
78
+ useGetBasketQuery.mockReturnValue({ data: basketWith('1299.90', 2) });
79
+
80
+ render(
81
+ <LiveBasketPageContext>
82
+ <BoundTotal />
83
+ </LiveBasketPageContext>
84
+ );
85
+
86
+ expect(screen.getByTestId('total')).toHaveTextContent('1.299,90 TL');
87
+ });
88
+
89
+ it('moves the bound total when the shopper changes quantity', () => {
90
+ useGetBasketQuery.mockReturnValue({ data: basketWith('1299.90', 2) });
91
+
92
+ const { rerender } = render(
93
+ <LiveBasketPageContext>
94
+ <BoundTotal />
95
+ </LiveBasketPageContext>
96
+ );
97
+ expect(screen.getByTestId('total')).toHaveTextContent('1.299,90 TL');
98
+
99
+ // What the RTK cache write after a quantity mutation looks like from here.
100
+ useGetBasketQuery.mockReturnValue({ data: basketWith('1949.85', 3) });
101
+ rerender(
102
+ <LiveBasketPageContext>
103
+ <BoundTotal />
104
+ </LiveBasketPageContext>
105
+ );
106
+
107
+ expect(screen.getByTestId('total')).toHaveTextContent('1.949,85 TL');
108
+ });
109
+ });
@@ -0,0 +1,156 @@
1
+ import {
2
+ buildOrderPageContext,
3
+ buildCheckoutPageContext,
4
+ buildFlatPageContext
5
+ } from '../page-context';
6
+ import { createPageContextDiscovery } from '../../utils/page-context-discovery';
7
+ import { resolveBlockBindings } from '../../utils/iterator-utils';
8
+
9
+ const textBlock = (dataBinding: string, value = 'Fallback') =>
10
+ ({
11
+ id: 'b',
12
+ type: 'text',
13
+ label: 'b',
14
+ value,
15
+ properties: { dataBinding },
16
+ styles: {},
17
+ order: 0,
18
+ hidden: false
19
+ } as never);
20
+
21
+ const orderPayload = {
22
+ id: 42,
23
+ number: 'ORD-42',
24
+ amount: '1299.90',
25
+ shipping_amount: '29.90',
26
+ created_date: '2026-07-30T10:00:00Z',
27
+ user_email: 'shopper@example.com',
28
+ currency: { value: 'TRY' },
29
+ status: { label: 'Shipped' },
30
+ shipping_address: {
31
+ first_name: 'Ada',
32
+ last_name: 'Lovelace',
33
+ line: 'Street 1',
34
+ district: 'Kadikoy',
35
+ city: 'Istanbul',
36
+ country: 'Turkey',
37
+ // Present on the real payload and deliberately not published.
38
+ identity_number: '11111111111',
39
+ phone: '5550000000'
40
+ },
41
+ orderitem_set: [
42
+ {
43
+ price: '649.95',
44
+ status: { label: 'Shipped' },
45
+ product: {
46
+ name: 'Spring Sneaker',
47
+ image: '/i.jpg',
48
+ absolute_url: '/p/1'
49
+ }
50
+ }
51
+ ],
52
+ discountitem_set: [{ name: 'Welcome', amount: '100.00' }]
53
+ };
54
+
55
+ describe('buildOrderPageContext', () => {
56
+ it('publishes the fields a receipt needs, under money-shaped names', () => {
57
+ const context = buildOrderPageContext({ order: orderPayload });
58
+
59
+ expect(context.page.type).toBe('order-success');
60
+ expect(context.order.number).toBe('ORD-42');
61
+ expect(context.order.totalAmount).toBe('1299.90');
62
+ expect(context.order.shippingAmount).toBe('29.90');
63
+ expect(context.order.currency).toBe('TRY');
64
+ expect(context.order.items).toHaveLength(1);
65
+ expect(context.order.items[0].title).toBe('Spring Sneaker');
66
+ });
67
+
68
+ it('does not publish the identity fields the raw payload carries', () => {
69
+ const context = buildOrderPageContext({ order: orderPayload });
70
+ const serialized = JSON.stringify(context);
71
+
72
+ expect(serialized).not.toContain('11111111111');
73
+ expect(serialized).not.toContain('5550000000');
74
+ expect(serialized).not.toContain('identity_number');
75
+ });
76
+
77
+ it('formats a bound order total as money', () => {
78
+ const result = resolveBlockBindings({
79
+ block: textBlock('order.totalAmount'),
80
+ pageContext: buildOrderPageContext({ order: orderPayload })
81
+ });
82
+
83
+ expect(result.value).toBe('1.299,90 TL');
84
+ });
85
+
86
+ it('labels its fields for the editor picker', () => {
87
+ const discovery = createPageContextDiscovery(
88
+ buildOrderPageContext({ order: orderPayload })
89
+ );
90
+ const byPath = Object.fromEntries(
91
+ discovery.entries.map((entry) => [entry.path, entry])
92
+ );
93
+
94
+ expect(discovery.roots).toEqual(expect.arrayContaining(['page', 'order']));
95
+ expect(byPath['order.totalAmount'].semantic).toBe('money');
96
+ expect(byPath['order.createdDate'].semantic).toBe('date');
97
+ expect(byPath['order.items'].semantic).toBe('list');
98
+ expect(byPath['order.items[].image'].semantic).toBe('image');
99
+ });
100
+
101
+ it('survives an order that has not loaded yet', () => {
102
+ const context = buildOrderPageContext({ order: null });
103
+
104
+ expect(context.order.number).toBe('');
105
+ expect(context.order.items).toEqual([]);
106
+ });
107
+ });
108
+
109
+ describe('buildCheckoutPageContext', () => {
110
+ const basket = {
111
+ pk: 1,
112
+ total_amount: '1299.90',
113
+ total_quantity: 2,
114
+ basketitem_set: [{ currency_type: 'TRY', product: { name: 'Sneaker' } }]
115
+ } as never;
116
+
117
+ it('republishes the basket root so a basket composition keeps working', () => {
118
+ const context = buildCheckoutPageContext({ basket });
119
+
120
+ expect(context.page.type).toBe('checkout');
121
+ expect(context.basket.totalAmount).toBe('1299.90');
122
+ expect(context.basket.currency).toBe('TRY');
123
+ });
124
+
125
+ it('adds the figures that exist only on the pre-order', () => {
126
+ const context = buildCheckoutPageContext({
127
+ basket,
128
+ preOrder: {
129
+ amount: '1329.80',
130
+ shipping_amount: '29.90',
131
+ unpaid_amount: '0.00',
132
+ shipping_option: { pk: 3, name: 'Express' },
133
+ currency: { value: 'TRY' }
134
+ }
135
+ });
136
+
137
+ expect(context.checkout.shippingAmount).toBe('29.90');
138
+ expect(context.checkout.shippingOption).toBe('Express');
139
+ expect(context.checkout.hasShippingOption).toBe(true);
140
+ });
141
+ });
142
+
143
+ describe('buildFlatPageContext', () => {
144
+ it('publishes a title and a slug to bind', () => {
145
+ const context = buildFlatPageContext({
146
+ title: 'Delivery',
147
+ slug: 'delivery',
148
+ content: '<p>hi</p>',
149
+ absolute_url: '/delivery'
150
+ });
151
+
152
+ expect(context.page.type).toBe('flat-page');
153
+ expect(context.flatPage.title).toBe('Delivery');
154
+ expect(context.flatPage.url).toBe('/delivery');
155
+ });
156
+ });
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { PropsWithChildren } from 'react';
4
+ import { useGetBasketQuery } from '@akinon/next/data/client/basket';
5
+
6
+ import { ThemePageContextProvider } from '../theme-page-context';
7
+ import { buildBasketPageContext } from './page-context';
8
+
9
+ /**
10
+ * Publishes the shopper's live basket as the page context for the slot below.
11
+ *
12
+ * The basket is session-scoped and has no server data module, so the context a
13
+ * server-rendered slot can build is a snapshot at best — it would freeze the
14
+ * moment a quantity changed. Subscribing the same RTK Query entry the basket
15
+ * blocks already use keeps a bound `basket.totalAmount` in step with the
16
+ * built-in summary beside it, and the render tree gets the same normalised
17
+ * field names either way because the same builder produces both.
18
+ *
19
+ * Wraps the slot rather than living inside it: ThemePlaceholderWrapper merges
20
+ * an inherited context OVER the one passed as a prop, so this wins as soon as
21
+ * the query resolves.
22
+ */
23
+ export const LiveBasketPageContext = ({
24
+ path,
25
+ children
26
+ }: PropsWithChildren<{ path?: string }>) => {
27
+ const { data: basket } = useGetBasketQuery(undefined as never);
28
+
29
+ // Null until the query answers. The merge upstream is a shallow top-level
30
+ // spread, so a hollow context here would mask the server-rendered one and
31
+ // blank the total for one paint.
32
+ const value = basket ? buildBasketPageContext({ basket, path }) : null;
33
+
34
+ return (
35
+ <ThemePageContextProvider value={value}>
36
+ {children}
37
+ </ThemePageContextProvider>
38
+ );
39
+ };
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { PropsWithChildren } from 'react';
4
+ import { useGetBasketQuery } from '@akinon/next/data/client/basket';
5
+ import { useFetchCheckoutQuery } from '@akinon/next/data/client/checkout';
6
+
7
+ import { ThemePageContextProvider } from '../theme-page-context';
8
+ import { buildCheckoutPageContext } from './page-context';
9
+
10
+ /**
11
+ * The zones above and below the checkout flow.
12
+ *
13
+ * Publishes the basket root unchanged next to a `checkout` root, so a
14
+ * composition authored on the basket page keeps resolving here and the
15
+ * shipping/total figures that live only on the pre-order become bindable.
16
+ *
17
+ * The flow itself stays code-owned — this provider gives the zones data, it
18
+ * does not hand the payment steps over.
19
+ */
20
+ export const LiveCheckoutPageContext = ({
21
+ path,
22
+ children
23
+ }: PropsWithChildren<{ path?: string }>) => {
24
+ const { data: basket } = useGetBasketQuery(undefined as never);
25
+ const { data: checkout } = useFetchCheckoutQuery(undefined as never);
26
+
27
+ const preOrder = (
28
+ checkout as unknown as { pre_order?: Record<string, unknown> } | undefined
29
+ )?.pre_order;
30
+
31
+ // Null until at least one of the two answers, so the server-published
32
+ // context is not masked by a hollow one.
33
+ const value =
34
+ basket || preOrder
35
+ ? buildCheckoutPageContext({ basket, preOrder, path })
36
+ : null;
37
+
38
+ return (
39
+ <ThemePageContextProvider value={value}>{children}</ThemePageContextProvider>
40
+ );
41
+ };
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import { PropsWithChildren } from 'react';
4
+ import { useGetOrderQuery } from '@akinon/next/data/client/account';
5
+ import { useFetchCheckoutResultQuery } from '@akinon/next/data/client/checkout';
6
+
7
+ import { ThemePageContextProvider } from '../theme-page-context';
8
+ import { buildOrderPageContext } from './page-context';
9
+
10
+ /**
11
+ * The shopper's order, published to the slot below.
12
+ *
13
+ * Orders have no server data module, so this is the only way a composed block
14
+ * on the success page or the account order detail can bind order data. Both
15
+ * screens publish the SAME `order` root, so a composition authored on one
16
+ * resolves on the other.
17
+ *
18
+ * Personal data: this never crosses the server boundary as a prop, which is
19
+ * what keeps an order out of a cached shell — see the projection in
20
+ * buildOrderPageContext.
21
+ */
22
+
23
+ const publish = (
24
+ order: unknown,
25
+ path: string | undefined,
26
+ pageType: string,
27
+ title: string,
28
+ children: React.ReactNode
29
+ ) => (
30
+ <ThemePageContextProvider
31
+ // Null until the query answers: the merge upstream is a shallow top-level
32
+ // spread, so a hollow context would mask whatever the server published.
33
+ value={order ? buildOrderPageContext({ order: order as never, path, pageType, title }) : null}
34
+ >
35
+ {children}
36
+ </ThemePageContextProvider>
37
+ );
38
+
39
+ /** Order success — the order is read back by the checkout result token. */
40
+ export const LiveOrderSuccessPageContext = ({
41
+ token,
42
+ path,
43
+ children
44
+ }: PropsWithChildren<{ token: string; path?: string }>) => {
45
+ const { data } = useFetchCheckoutResultQuery(token, { skip: !token });
46
+
47
+ return publish(
48
+ (data as { order?: unknown } | undefined)?.order,
49
+ path,
50
+ 'order-success',
51
+ 'Order Success',
52
+ children
53
+ );
54
+ };
55
+
56
+ /** Account order detail — same root, fetched by order id. */
57
+ export const LiveAccountOrderPageContext = ({
58
+ orderId,
59
+ path,
60
+ children
61
+ }: PropsWithChildren<{ orderId: string; path?: string }>) => {
62
+ const { data } = useGetOrderQuery(orderId, { skip: !orderId });
63
+
64
+ return publish(data, path, 'order-detail', 'Order Detail', children);
65
+ };