@akinon/projectzero 2.0.43 → 2.0.45-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.
Files changed (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app-template/CHANGELOG.md +66 -1
  3. package/app-template/package.json +29 -29
  4. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +30 -11
  5. package/app-template/src/app/[pz]/orders/checkout/page.tsx +31 -18
  6. package/app-template/src/components/checkbox.tsx +21 -2
  7. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +121 -0
  8. package/app-template/src/views/checkout/step-button.tsx +13 -2
  9. package/app-template/src/views/checkout/step-list.tsx +5 -1
  10. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  11. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  12. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  13. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  14. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  15. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  16. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  17. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  18. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  19. package/app-template/src/views/checkout/summary.tsx +56 -18
  20. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  21. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  22. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +7 -4
  23. package/app-template/src/views/checkout/variants/multi-step/index.tsx +7 -2
  24. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +22 -0
  25. package/app-template/src/views/checkout/variants/one-page/index.tsx +7 -4
  26. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +80 -18
  27. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +34 -22
  28. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +2 -0
  29. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +2 -0
  30. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +2 -0
  31. package/app-template/src/views/checkout/variants/registry.ts +13 -2
  32. package/app-template/src/views/checkout/variants/use-resolved-config.ts +77 -5
  33. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { RootState } from '@theme/redux/store';
4
4
  import { useSetPaymentOptionMutation } from '@akinon/next/data/client/checkout';
5
5
  import { CheckoutPaymentOption } from '@akinon/next/types';
6
6
  import { Radio } from '@theme/components';
7
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
7
8
  import { usePaymentOptions } from '@akinon/next/hooks/use-payment-options';
8
9
  import { useMemo } from 'react';
9
10
 
@@ -42,10 +43,20 @@ const PaymentOptionButtons = () => {
42
43
  <>
43
44
  <div className="w-full space-y-4 px-4 flex flex-col mb-8 md:hidden">
44
45
  {displayedPaymentOptions.map((option) => (
46
+ // The mobile radio row's typography sits on the <label> — the
47
+ // element the payment-option part is stamped on — so an inherited
48
+ // part rule actually reaches the method name. Declared on the inner
49
+ // span (as it was) every typography control in the panel was dead
50
+ // here while working on the desktop tab below, which made a
51
+ // template's white-on-dark selected state readable on desktop and
52
+ // black-on-black on every phone.
45
53
  <label
46
54
  key={`payment-option-${option.pk}`}
47
- className="border px-4 py-3 mt-3 flex h-12"
55
+ className="border px-4 py-3 mt-3 flex h-12 text-primary-800 font-light text-lg"
48
56
  onClick={scrollToTop}
57
+ {...partAttrs('payment-option', {
58
+ selected: preOrder?.payment_option?.pk === option.pk
59
+ })}
49
60
  >
50
61
  <Radio
51
62
  type="radio"
@@ -55,9 +66,7 @@ const PaymentOptionButtons = () => {
55
66
  onChange={() => onClickHandler(option)}
56
67
  className="mr-2 mt-1"
57
68
  />
58
- <span className="flex text-primary-800 font-light text-lg">
59
- {option.name}
60
- </span>
69
+ <span className="flex">{option.name}</span>
61
70
  </label>
62
71
  ))}
63
72
  </div>
@@ -77,6 +86,9 @@ const PaymentOptionButtons = () => {
77
86
  }
78
87
  )}
79
88
  data-testid={`checkout-payment-tab-${option.pk}`}
89
+ {...partAttrs('payment-option', {
90
+ selected: preOrder?.payment_option?.pk === option.pk
91
+ })}
80
92
  >
81
93
  {option.name}
82
94
  </button>
@@ -5,6 +5,7 @@ import { RootState } from '@theme/redux/store';
5
5
  import { useSetAddressesMutation } from '@akinon/next/data/client/checkout';
6
6
  import { CheckoutAddressType } from '@akinon/next/types';
7
7
  import { Checkbox, Icon, Modal } from '@theme/components';
8
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
8
9
  import AddressBox from './address-box';
9
10
  import { useAddAddressMutation } from '@akinon/next/data/client/address';
10
11
  import { AddressForm } from '@theme/views/account/address-form';
@@ -102,7 +103,7 @@ const Addresses = () => {
102
103
  .map((addressType) => (
103
104
  <div key={addressType.label}>
104
105
  <div className="flex items-center justify-between border-b border-gray-400 px-8 py-4">
105
- <h2 className="text-2xl">
106
+ <h2 className="text-2xl" {...partAttrs('section-title')}>
106
107
  {addressType.text} {t('checkout.address.address')}
107
108
  </h2>
108
109
 
@@ -1,5 +1,6 @@
1
1
  import clsx from 'clsx';
2
2
  import { useAppSelector } from '@akinon/next/redux/hooks';
3
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
3
4
  import { RootState } from '@theme/redux/store';
4
5
  import Addresses from './addresses';
5
6
  import ShippingOptions from './shipping-options';
@@ -17,6 +18,7 @@ const ShippingStep = () => {
17
18
  'pointer-events-none opacity-30': isShippingStepBusy
18
19
  }
19
20
  )}
21
+ {...partAttrs('section')}
20
22
  >
21
23
  <Addresses />
22
24
  <ShippingOptions />
@@ -11,6 +11,7 @@ import {
11
11
  useSetDataSourceShippingOptionsMutation
12
12
  } from '@akinon/next/data/client/checkout';
13
13
  import { Price, Button, Radio } from '@theme/components';
14
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
14
15
  import { CheckoutStep } from '@akinon/next/types';
15
16
  import { useLocalization } from '@akinon/next/hooks';
16
17
 
@@ -267,6 +268,7 @@ const ShippingOptions: React.FC = () => {
267
268
  disabled={!steps.shipping.completed}
268
269
  onClick={() => dispatch(setCurrentStep(CheckoutStep.Payment))}
269
270
  data-testid="checkout-shipping-save"
271
+ {...partAttrs('continue-button')}
270
272
  >
271
273
  {t('checkout.address.shipping.button')}
272
274
  </Button>
@@ -2,6 +2,7 @@ import { setCurrentStep } from '@akinon/next/redux/reducers/checkout';
2
2
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
3
3
  import { RootState } from '@theme/redux/store';
4
4
  import { Price, Link } from '@theme/components';
5
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
5
6
  import { CheckoutStep } from '@akinon/next/types';
6
7
  import { useLocalization } from '@akinon/next/hooks';
7
8
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
@@ -40,9 +41,18 @@ export const Summary = () => {
40
41
  }}
41
42
  />
42
43
  <StoreCredits />
43
- <div className="flex flex-col w-full border border-solid border-gray-400">
44
- <div className="flex justify-between items-center flex-row border-b border-solid border-gray-400 px-4 py-2 sm:px-5 sm:py-4 sm:min-h-15">
45
- <span className="text-black-800 text-xl font-light sm:text-2xl">
44
+ <div
45
+ className="flex flex-col w-full border border-solid border-gray-400"
46
+ {...partAttrs('summary')}
47
+ >
48
+ <div
49
+ className="flex justify-between items-center flex-row border-b border-solid border-gray-400 px-4 py-2 sm:px-5 sm:py-4 sm:min-h-15"
50
+ {...partAttrs('summary-header')}
51
+ >
52
+ <span
53
+ className="text-black-800 text-xl font-light sm:text-2xl"
54
+ {...partAttrs('summary-title')}
55
+ >
46
56
  {t('checkout.summary.title')}
47
57
  </span>
48
58
  <span className="text-gray-950 text-xs">
@@ -55,6 +65,7 @@ export const Summary = () => {
55
65
  <div
56
66
  key={`summary-basketitem-${index}`}
57
67
  className="flex flex-row border-b border-solid border-gray-400 py-3 px-4 last:border-b-0 sm:px-5"
68
+ {...partAttrs('summary-item')}
58
69
  >
59
70
  <Link
60
71
  href={'#'}
@@ -104,42 +115,68 @@ export const Summary = () => {
104
115
  ))}
105
116
  </div>
106
117
  <div className="pt-3">
107
- <div className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5">
108
- <span>
118
+ <div
119
+ className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5"
120
+ {...partAttrs('summary-row')}
121
+ >
122
+ <span {...partAttrs('summary-row-label')}>
109
123
  {t('checkout.summary.subtotal')} (
110
124
  {preOrder.basket.basketitem_set.length}{' '}
111
125
  {t('checkout.summary.items')})
112
126
  </span>
113
- <span>
127
+ <span {...partAttrs('summary-row-value')}>
114
128
  <Price value={preOrder?.basket?.total_amount} />
115
129
  </span>
116
130
  </div>
117
- <div className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5">
118
- <span>{t('checkout.summary.shipping')}</span>
119
- <span>
131
+ <div
132
+ className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5"
133
+ {...partAttrs('summary-row')}
134
+ >
135
+ <span {...partAttrs('summary-row-label')}>
136
+ {t('checkout.summary.shipping')}
137
+ </span>
138
+ <span {...partAttrs('summary-row-value')}>
120
139
  <Price value={preOrder?.shipping_amount} />
121
140
  </span>
122
141
  </div>
123
142
  {parseFloat(preOrder?.loyalty_money) > 0 && (
124
- <div className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5">
125
- <span>{t('checkout.summary.loyalty_money_total')}</span>
126
- <span>
143
+ <div
144
+ className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5"
145
+ {...partAttrs('summary-row')}
146
+ >
147
+ <span {...partAttrs('summary-row-label')}>
148
+ {t('checkout.summary.loyalty_money_total')}
149
+ </span>
150
+ <span {...partAttrs('summary-row-value')}>
127
151
  <Price value={preOrder?.loyalty_money} />
128
152
  </span>
129
153
  </div>
130
154
  )}
131
- <div className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5">
132
- <span>{t('checkout.summary.discounts_total')}</span>
133
- <span>
155
+ <div
156
+ className="flex items-center justify-between w-full text-xs text-black-800 py-1 px-4 sm:px-5"
157
+ {...partAttrs('summary-row')}
158
+ >
159
+ <span {...partAttrs('summary-row-label')}>
160
+ {t('checkout.summary.discounts_total')}
161
+ </span>
162
+ <span {...partAttrs('summary-row-value')}>
134
163
  <Price
135
164
  value={preOrder?.basket?.total_discount_amount}
136
165
  useNegative
137
166
  />
138
167
  </span>
139
168
  </div>
140
- <div className="flex items-center justify-between w-full text-black-800 px-4 sm:px-5 text-lg border-t border-solid border-gray-400 py-2 mt-3 sm:text-xl sm:py-3">
141
- <span className="font-light">{t('checkout.summary.total')}</span>
142
- <span className="min-w-max pl-4">
169
+ <div
170
+ className="flex items-center justify-between w-full text-black-800 px-4 sm:px-5 text-lg border-t border-solid border-gray-400 py-2 mt-3 sm:text-xl sm:py-3"
171
+ {...partAttrs('summary-total')}
172
+ >
173
+ <span className="font-light" {...partAttrs('summary-total-label')}>
174
+ {t('checkout.summary.total')}
175
+ </span>
176
+ <span
177
+ className="min-w-max pl-4"
178
+ {...partAttrs('summary-total-value')}
179
+ >
143
180
  <Price value={preOrder?.unpaid_amount} />
144
181
  </span>
145
182
  </div>
@@ -154,6 +191,7 @@ export const Summary = () => {
154
191
  <div
155
192
  className="text-xs text-black-800 italic cursor-pointer underline transition-all hover:text-secondary"
156
193
  onClick={() => dispatch(setCurrentStep(CheckoutStep.Shipping))}
194
+ {...partAttrs('summary-edit')}
157
195
  >
158
196
  {t('checkout.summary.change')}
159
197
  </div>
@@ -0,0 +1,82 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { getCheckoutVariant, isKnownVariant } from '../registry';
3
+
4
+ jest.mock('../multi-step', () => ({
5
+ __esModule: true,
6
+ default: () => <div data-testid="variant-multi-step" />
7
+ }));
8
+
9
+ jest.mock('../one-page', () => ({
10
+ __esModule: true,
11
+ default: () => <div data-testid="variant-one-page" />
12
+ }));
13
+
14
+ jest.mock('../basket-and-checkout', () => ({
15
+ __esModule: true,
16
+ default: () => <div data-testid="variant-basket-and-checkout" />
17
+ }));
18
+
19
+ /**
20
+ * The variant registry is the last hop before the checkout flow renders, and
21
+ * the value it receives is untrusted: it comes from settings.js, from the
22
+ * backend theme-settings widget, or (now) from a composition. A lookup that
23
+ * accepts inherited Object members would answer "known" for 'constructor' and
24
+ * then hand `dynamic()` something that is not a loader — the flow would never
25
+ * render and ordering would be impossible (I2). Every corpus entry must still
26
+ * end at a real variant component.
27
+ */
28
+ const UNTRUSTED_CORPUS: unknown[] = [
29
+ 'toString',
30
+ 'constructor',
31
+ 'valueOf',
32
+ 'hasOwnProperty',
33
+ '__proto__',
34
+ '',
35
+ undefined,
36
+ 42,
37
+ 'multi-step',
38
+ 'one-page'
39
+ ];
40
+
41
+ describe('isKnownVariant', () => {
42
+ it('accepts only own keys of the loader map', () => {
43
+ expect(isKnownVariant('multi-step')).toBe(true);
44
+ expect(isKnownVariant('one-page')).toBe(true);
45
+ expect(isKnownVariant('accordion')).toBe(true);
46
+ expect(isKnownVariant('basket-and-checkout')).toBe(true);
47
+ });
48
+
49
+ it.each(
50
+ UNTRUSTED_CORPUS.filter(
51
+ (value) => value !== 'multi-step' && value !== 'one-page'
52
+ )
53
+ )('rejects %p', (value) => {
54
+ expect(isKnownVariant(value)).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe('getCheckoutVariant', () => {
59
+ it.each(UNTRUSTED_CORPUS)('renders a real variant for %p', async (value) => {
60
+ const Variant = getCheckoutVariant(value as never);
61
+
62
+ render(<Variant />);
63
+
64
+ const expected =
65
+ value === 'one-page' ? 'variant-one-page' : 'variant-multi-step';
66
+
67
+ expect(await screen.findByTestId(expected)).toBeInTheDocument();
68
+ });
69
+
70
+ it('maps every known variant id to its component', async () => {
71
+ const Accordion = getCheckoutVariant('accordion');
72
+ render(<Accordion />);
73
+ // 'accordion' is an alias of the one-page layout.
74
+ expect(await screen.findByTestId('variant-one-page')).toBeInTheDocument();
75
+
76
+ const Unified = getCheckoutVariant('basket-and-checkout');
77
+ render(<Unified />);
78
+ expect(
79
+ await screen.findByTestId('variant-basket-and-checkout')
80
+ ).toBeInTheDocument();
81
+ });
82
+ });
@@ -0,0 +1,208 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import {
3
+ registerCheckoutOutlet,
4
+ resetCheckoutPiecesForTests
5
+ } from '@akinon/pz-theme/src/chrome/checkout-pieces';
6
+ import settingsModule from '../../../../settings';
7
+ import { useResolvedCheckoutConfig } from '../use-resolved-config';
8
+
9
+ // Same module the hook imports as `@theme/settings` — jest keys mocks by
10
+ // resolved path, and the `@theme/*` alias is not resolvable from a test file.
11
+ // Mocking it keeps the suite off the real storefront settings (and its
12
+ // runtime-only requires) while letting each case state its own baseline.
13
+ jest.mock('../../../../settings', () => ({
14
+ __esModule: true,
15
+ default: {}
16
+ }));
17
+
18
+ const mockSettings = settingsModule as { checkout?: Record<string, unknown> };
19
+
20
+ const themeSettingsResponse = (body: unknown) => {
21
+ (global.fetch as jest.Mock).mockResolvedValue({
22
+ ok: true,
23
+ json: async () => body
24
+ });
25
+ };
26
+
27
+ const registerFlowOutlet = (flowVariant?: unknown, id = 'outlet-1') => {
28
+ registerCheckoutOutlet('flow', {
29
+ id,
30
+ node: document.createElement('div'),
31
+ flowVariant: flowVariant as never
32
+ });
33
+ };
34
+
35
+ /**
36
+ * Three layers resolve the checkout layout — settings.js, the backend
37
+ * theme-settings widget, and the composition's flow outlet — and the order
38
+ * matters in both directions: a composition must be able to say "this page is
39
+ * the one-page checkout", and nothing may be able to say "no checkout at all".
40
+ * These tests pin the precedence, the closed mapping (an unrecognised
41
+ * preference always falls through to the inherited variant), and the live
42
+ * latch that keeps a shopper's flow from swapping mid-session.
43
+ */
44
+ describe('useResolvedCheckoutConfig', () => {
45
+ beforeEach(() => {
46
+ resetCheckoutPiecesForTests();
47
+ delete mockSettings.checkout;
48
+ delete window.__themeEditorDesigner;
49
+ global.fetch = jest
50
+ .fn()
51
+ .mockResolvedValue({ ok: true, json: async () => ({}) });
52
+ });
53
+
54
+ afterEach(() => {
55
+ jest.resetAllMocks();
56
+ });
57
+
58
+ it('falls back to the step-by-step flow when nothing is configured', async () => {
59
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
60
+
61
+ expect(result.current.variantId).toBe('multi-step');
62
+ expect(result.current.options).toEqual({});
63
+ await act(async () => undefined);
64
+ });
65
+
66
+ it('uses the settings.js baseline when the backend has no override', async () => {
67
+ mockSettings.checkout = {
68
+ variant: 'one-page',
69
+ options: { showTrustBadges: false }
70
+ };
71
+
72
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
73
+
74
+ expect(result.current.variantId).toBe('one-page');
75
+ expect(result.current.options).toEqual({ showTrustBadges: false });
76
+ await act(async () => undefined);
77
+ });
78
+
79
+ it('lets the theme-settings response override settings.js', async () => {
80
+ mockSettings.checkout = { variant: 'multi-step' };
81
+ themeSettingsResponse({ checkout: { variant: 'one-page' } });
82
+
83
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
84
+
85
+ await waitFor(() => expect(result.current.variantId).toBe('one-page'));
86
+ });
87
+
88
+ it('coerces options from both layers, dropping unknown and mistyped keys', async () => {
89
+ mockSettings.checkout = {
90
+ options: { autoAdvance: false, compactMode: true }
91
+ };
92
+ themeSettingsResponse({
93
+ checkout: {
94
+ options: { compactMode: 'yes', gtmEnabled: false, madeUpKey: 'x' }
95
+ }
96
+ });
97
+
98
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
99
+
100
+ await waitFor(() =>
101
+ expect(result.current.options).toEqual({
102
+ autoAdvance: false,
103
+ compactMode: true,
104
+ gtmEnabled: false
105
+ })
106
+ );
107
+ });
108
+
109
+ it('lets a composed flow outlet beat settings.js and the backend', async () => {
110
+ mockSettings.checkout = { variant: 'multi-step' };
111
+ themeSettingsResponse({ checkout: { variant: 'multi-step' } });
112
+ registerFlowOutlet('one-page');
113
+
114
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
115
+
116
+ expect(result.current.variantId).toBe('one-page');
117
+ // The later theme-settings arrival must not displace the latched layout.
118
+ await act(async () => undefined);
119
+ expect(result.current.variantId).toBe('one-page');
120
+ });
121
+
122
+ it('maps step-by-step to the multi-step variant', async () => {
123
+ mockSettings.checkout = { variant: 'one-page' };
124
+ registerFlowOutlet('step-by-step');
125
+
126
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
127
+
128
+ expect(result.current.variantId).toBe('multi-step');
129
+ await act(async () => undefined);
130
+ });
131
+
132
+ it.each([
133
+ 'accordion',
134
+ 'basket-and-checkout',
135
+ 'constructor',
136
+ 'toString',
137
+ '',
138
+ 42,
139
+ null
140
+ ])(
141
+ 'falls through to the inherited variant for the preference %p',
142
+ async (preference) => {
143
+ mockSettings.checkout = { variant: 'one-page' };
144
+ registerFlowOutlet(preference);
145
+
146
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
147
+
148
+ expect(result.current.variantId).toBe('one-page');
149
+ await act(async () => undefined);
150
+ }
151
+ );
152
+
153
+ it('never lets a composition add options of its own', async () => {
154
+ mockSettings.checkout = { options: { stickyMobileCta: false } };
155
+ registerFlowOutlet('one-page');
156
+
157
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
158
+
159
+ expect(result.current.options).toEqual({ stickyMobileCta: false });
160
+ await act(async () => undefined);
161
+ });
162
+
163
+ it('ignores an outlet that registers after the first commit', async () => {
164
+ mockSettings.checkout = { variant: 'multi-step' };
165
+
166
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
167
+
168
+ expect(result.current.variantId).toBe('multi-step');
169
+
170
+ act(() => registerFlowOutlet('one-page'));
171
+
172
+ expect(result.current.variantId).toBe('multi-step');
173
+ await act(async () => undefined);
174
+ });
175
+
176
+ it('follows the live claim on the designer canvas', async () => {
177
+ window.__themeEditorDesigner = true;
178
+ mockSettings.checkout = { variant: 'multi-step' };
179
+
180
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
181
+
182
+ expect(result.current.variantId).toBe('multi-step');
183
+
184
+ act(() => registerFlowOutlet('one-page'));
185
+
186
+ expect(result.current.variantId).toBe('one-page');
187
+ await act(async () => undefined);
188
+ });
189
+
190
+ it('resolves exactly like the two-layer storefront when nothing registers', async () => {
191
+ mockSettings.checkout = {
192
+ variant: 'multi-step',
193
+ options: { showOrderReview: false }
194
+ };
195
+ themeSettingsResponse({
196
+ checkout: { variant: 'one-page', options: { enableEditMode: false } }
197
+ });
198
+
199
+ const { result } = renderHook(() => useResolvedCheckoutConfig());
200
+
201
+ await waitFor(() =>
202
+ expect(result.current).toEqual({
203
+ variantId: 'one-page',
204
+ options: { showOrderReview: false, enableEditMode: false }
205
+ })
206
+ );
207
+ });
208
+ });
@@ -3,6 +3,7 @@
3
3
  import { useCallback } from 'react';
4
4
  import clsx from 'clsx';
5
5
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
6
+ import { CheckoutPiece } from '@akinon/pz-theme/src/chrome/checkout-piece';
6
7
  import ContactSection from '../one-page/sections/contact-section';
7
8
  import AddressSection from '../one-page/sections/address-section';
8
9
  import ShippingSection from '../one-page/sections/shipping-section';
@@ -104,10 +105,12 @@ const BasketAndCheckout = ({ options }: CheckoutVariantProps) => {
104
105
  data-testid="basket-and-checkout-summary"
105
106
  >
106
107
  <div className="sticky top-6">
107
- <OnePageSummary
108
- onEditSection={openSection}
109
- showReview={showOrderReview}
110
- />
108
+ <CheckoutPiece piece="summary">
109
+ <OnePageSummary
110
+ onEditSection={openSection}
111
+ showReview={showOrderReview}
112
+ />
113
+ </CheckoutPiece>
111
114
  </div>
112
115
  </aside>
113
116
  </div>
@@ -6,6 +6,7 @@ import { setCurrentStep } from '@akinon/next/redux/reducers/checkout';
6
6
  import { CheckoutStep } from '@akinon/next/types';
7
7
  import { RootState } from '@theme/redux/store';
8
8
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
9
+ import { CheckoutPiece } from '@akinon/pz-theme/src/chrome/checkout-piece';
9
10
  import { CheckoutStepList } from '@theme/views/checkout/step-list';
10
11
  import { Summary } from '@theme/views/checkout/summary';
11
12
  import ShippingStep from '@theme/views/checkout/steps/shipping';
@@ -53,7 +54,9 @@ const MultiStepCheckout = (_props: CheckoutVariantProps) => {
53
54
  <PluginModule component={Component.MasterpassLinkModal} />
54
55
 
55
56
  <div className="container flex flex-col flex-wrap w-full px-4 md:px-0">
56
- <CheckoutStepList />
57
+ <CheckoutPiece piece="steps">
58
+ <CheckoutStepList />
59
+ </CheckoutPiece>
57
60
 
58
61
  <div className="w-full flex flex-wrap">
59
62
  <div className="w-full h-fit-content lg:w-2/3">
@@ -62,7 +65,9 @@ const MultiStepCheckout = (_props: CheckoutVariantProps) => {
62
65
  </div>
63
66
 
64
67
  <div className="w-full h-fit-content mt-6 lg:w-1/3 lg:pl-8 lg:mt-0">
65
- <Summary />
68
+ <CheckoutPiece piece="summary">
69
+ <Summary />
70
+ </CheckoutPiece>
66
71
  </div>
67
72
  </div>
68
73
  </div>
@@ -1,6 +1,7 @@
1
1
  import { ReactNode, useEffect, useRef, useId } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useLocalization } from '@akinon/next/hooks';
4
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
4
5
  import { Icon } from '@theme/components';
5
6
 
6
7
  export type SectionStatus = 'locked' | 'active' | 'completed';
@@ -61,6 +62,11 @@ export const AccordionSection = ({
61
62
  data-testid={dataTestId}
62
63
  data-status={status}
63
64
  aria-labelledby={headerId}
65
+ {...partAttrs('section', {
66
+ open: isOpen,
67
+ completed: isCompleted,
68
+ disabled: isLocked
69
+ })}
64
70
  >
65
71
  <header
66
72
  id={headerId}
@@ -68,6 +74,11 @@ export const AccordionSection = ({
68
74
  'flex items-center justify-between gap-3 px-6 py-5 sm:px-7',
69
75
  isOpen && 'border-b border-gray-100'
70
76
  )}
77
+ {...partAttrs('section-header', {
78
+ open: isOpen,
79
+ completed: isCompleted,
80
+ disabled: isLocked
81
+ })}
71
82
  >
72
83
  <div className="flex items-center gap-3.5">
73
84
  <span
@@ -78,6 +89,11 @@ export const AccordionSection = ({
78
89
  isLocked && 'bg-gray-100 text-gray-400'
79
90
  )}
80
91
  aria-hidden="true"
92
+ {...partAttrs('section-number', {
93
+ open: isOpen,
94
+ completed: isCompleted,
95
+ disabled: isLocked
96
+ })}
81
97
  >
82
98
  {isCompleted ? (
83
99
  <Icon name="check" size={14} className="fill-white" />
@@ -90,6 +106,11 @@ export const AccordionSection = ({
90
106
  'text-lg font-semibold leading-tight tracking-tight',
91
107
  isOpen || isCompleted ? 'text-black-800' : 'text-gray-500'
92
108
  )}
109
+ {...partAttrs('section-title', {
110
+ open: isOpen,
111
+ completed: isCompleted,
112
+ disabled: isLocked
113
+ })}
93
114
  >
94
115
  {title}
95
116
  </h2>
@@ -107,6 +128,7 @@ export const AccordionSection = ({
107
128
  )}
108
129
  data-testid={`${dataTestId}-edit`}
109
130
  aria-label={`${t('checkout.one_page.edit')} ${title}`}
131
+ {...partAttrs('edit-button')}
110
132
  >
111
133
  <span>{t('checkout.one_page.edit')}</span>
112
134
  </button>
@@ -3,6 +3,7 @@
3
3
  import { useCallback } from 'react';
4
4
  import clsx from 'clsx';
5
5
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
6
+ import { CheckoutPiece } from '@akinon/pz-theme/src/chrome/checkout-piece';
6
7
  import ContactSection from './sections/contact-section';
7
8
  import AddressSection from './sections/address-section';
8
9
  import ShippingSection from './sections/shipping-section';
@@ -97,10 +98,12 @@ const OnePageCheckout = ({ options }: CheckoutVariantProps) => {
97
98
  data-testid="one-page-summary"
98
99
  >
99
100
  <div className="sticky top-6">
100
- <OnePageSummary
101
- onEditSection={openSection}
102
- showReview={showOrderReview}
103
- />
101
+ <CheckoutPiece piece="summary">
102
+ <OnePageSummary
103
+ onEditSection={openSection}
104
+ showReview={showOrderReview}
105
+ />
106
+ </CheckoutPiece>
104
107
  </div>
105
108
  </aside>
106
109
  </div>