@akinon/pz-theme 2.0.39-beta.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 +5 -4
- package/package.json +4 -4
- package/src/__tests__/page-context-section-binding.test.tsx +104 -0
- package/src/chrome/__tests__/live-basket-page-context.test.tsx +109 -0
- package/src/chrome/__tests__/page-context-builders.test.ts +156 -0
- package/src/chrome/live-basket-page-context.tsx +39 -0
- package/src/chrome/live-checkout-page-context.tsx +41 -0
- package/src/chrome/live-order-page-context.tsx +65 -0
- package/src/chrome/page-context.ts +172 -2
- package/src/index.ts +19 -0
- package/src/theme-placeholder-client.tsx +4 -0
- package/src/theme-placeholder-wrapper.tsx +12 -1
- package/src/theme-section.tsx +8 -3
- package/src/utils/__tests__/generator-passthrough.test.ts +92 -0
- package/src/utils/field-semantics.test.ts +72 -0
- package/src/utils/field-semantics.ts +76 -0
- package/src/utils/iterator-utils.test.ts +3 -1
- package/src/utils/iterator-utils.ts +89 -6
- package/src/utils/page-context-binding.test.ts +136 -0
- package/src/utils/page-context-discovery.ts +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.39
|
|
3
|
+
## 2.0.39
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
- @akinon/theme-contract@2.0.39
|
|
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
|
|
10
11
|
|
|
11
12
|
## 2.0.38
|
|
12
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|