@akinon/projectzero 2.0.43-rc.0 → 2.0.44

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 (46) hide show
  1. package/CHANGELOG.md +8 -4
  2. package/app-template/.env.example +0 -1
  3. package/app-template/AGENTS.md +0 -8
  4. package/app-template/CHANGELOG.md +64 -78
  5. package/app-template/README.md +1 -25
  6. package/app-template/next.config.mjs +1 -7
  7. package/app-template/package.json +41 -41
  8. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +30 -11
  9. package/app-template/src/app/[pz]/orders/checkout/page.tsx +31 -18
  10. package/app-template/src/components/checkbox.tsx +21 -2
  11. package/app-template/src/hooks/index.ts +0 -2
  12. package/app-template/src/plugins.js +0 -1
  13. package/app-template/src/proxy.ts +1 -2
  14. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +121 -0
  15. package/app-template/src/views/checkout/step-button.tsx +13 -2
  16. package/app-template/src/views/checkout/step-list.tsx +5 -1
  17. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  18. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  19. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  20. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  21. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  22. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  23. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  24. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  25. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  26. package/app-template/src/views/checkout/summary.tsx +56 -18
  27. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  28. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  29. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +7 -4
  30. package/app-template/src/views/checkout/variants/multi-step/index.tsx +7 -2
  31. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +22 -0
  32. package/app-template/src/views/checkout/variants/one-page/index.tsx +7 -4
  33. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +80 -18
  34. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +34 -22
  35. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +2 -0
  36. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +2 -0
  37. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +2 -0
  38. package/app-template/src/views/checkout/variants/registry.ts +13 -2
  39. package/app-template/src/views/checkout/variants/use-resolved-config.ts +77 -5
  40. package/app-template/src/views/header/search/index.tsx +5 -13
  41. package/app-template/src/views/product/slider.tsx +38 -85
  42. package/commands/plugins.ts +0 -4
  43. package/dist/commands/plugins.js +0 -4
  44. package/package.json +1 -1
  45. package/app-template/src/app/api/client-env/route.ts +0 -5
  46. package/app-template/src/app/global-error.tsx +0 -22
@@ -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>
@@ -2,6 +2,7 @@ import { useAppSelector } from '@akinon/next/redux/hooks';
2
2
  import { useLocalization } from '@akinon/next/hooks';
3
3
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
4
4
  import { Price, Link } from '@theme/components';
5
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
5
6
  import { Image } from '@akinon/next/components/image';
6
7
  import { StoreCredits } from '@theme/views/checkout/steps/payment/options/store-credit';
7
8
  import type { RootState } from '@theme/redux/store';
@@ -37,9 +38,18 @@ const OnePageSummary = ({
37
38
  />
38
39
  <StoreCredits />
39
40
 
40
- <section className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]">
41
- <header className="flex items-baseline justify-between px-6 py-5">
42
- <span className="text-lg font-semibold tracking-tight text-black-800">
41
+ <section
42
+ className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]"
43
+ {...partAttrs('summary')}
44
+ >
45
+ <header
46
+ className="flex items-baseline justify-between px-6 py-5"
47
+ {...partAttrs('summary-header')}
48
+ >
49
+ <span
50
+ className="text-lg font-semibold tracking-tight text-black-800"
51
+ {...partAttrs('summary-title')}
52
+ >
43
53
  {t('checkout.summary.title')}
44
54
  </span>
45
55
  <span className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
@@ -52,6 +62,7 @@ const OnePageSummary = ({
52
62
  <li
53
63
  key={`one-page-summary-item-${index}`}
54
64
  className="flex gap-3 px-6 py-4"
65
+ {...partAttrs('summary-item')}
55
66
  >
56
67
  <Link
57
68
  href={item.product.absolute_url || '#'}
@@ -100,30 +111,67 @@ const OnePageSummary = ({
100
111
  </ul>
101
112
 
102
113
  <dl className="space-y-2.5 border-t border-gray-100 px-6 py-5 text-sm text-gray-600">
103
- <div className="flex items-center justify-between">
104
- <dt>{t('checkout.summary.subtotal')}</dt>
105
- <dd className="font-medium tabular-nums text-black-800">
114
+ <div
115
+ className="flex items-center justify-between"
116
+ {...partAttrs('summary-row')}
117
+ >
118
+ <dt {...partAttrs('summary-row-label')}>
119
+ {t('checkout.summary.subtotal')}
120
+ </dt>
121
+ {/*
122
+ The amount declares its own weight and colour, so a rule on the
123
+ row can never reach it by inheritance — it gets its own part
124
+ instead of leaving the row's Text Color a dead control.
125
+ */}
126
+ <dd
127
+ className="font-medium tabular-nums text-black-800"
128
+ {...partAttrs('summary-row-value')}
129
+ >
106
130
  <Price value={preOrder.basket.total_amount} />
107
131
  </dd>
108
132
  </div>
109
- <div className="flex items-center justify-between">
110
- <dt>{t('checkout.summary.shipping')}</dt>
111
- <dd className="font-medium tabular-nums text-black-800">
133
+ <div
134
+ className="flex items-center justify-between"
135
+ {...partAttrs('summary-row')}
136
+ >
137
+ <dt {...partAttrs('summary-row-label')}>
138
+ {t('checkout.summary.shipping')}
139
+ </dt>
140
+ <dd
141
+ className="font-medium tabular-nums text-black-800"
142
+ {...partAttrs('summary-row-value')}
143
+ >
112
144
  <Price value={preOrder.shipping_amount} />
113
145
  </dd>
114
146
  </div>
115
147
  {parseFloat(preOrder.loyalty_money || '0') > 0 && (
116
- <div className="flex items-center justify-between">
117
- <dt>{t('checkout.summary.loyalty_money_total')}</dt>
118
- <dd className="font-medium tabular-nums text-black-800">
148
+ <div
149
+ className="flex items-center justify-between"
150
+ {...partAttrs('summary-row')}
151
+ >
152
+ <dt {...partAttrs('summary-row-label')}>
153
+ {t('checkout.summary.loyalty_money_total')}
154
+ </dt>
155
+ <dd
156
+ className="font-medium tabular-nums text-black-800"
157
+ {...partAttrs('summary-row-value')}
158
+ >
119
159
  <Price value={preOrder.loyalty_money} />
120
160
  </dd>
121
161
  </div>
122
162
  )}
123
163
  {parseFloat(preOrder.basket.total_discount_amount || '0') > 0 && (
124
- <div className="flex items-center justify-between">
125
- <dt>{t('checkout.summary.discounts_total')}</dt>
126
- <dd className="font-medium tabular-nums text-black-800">
164
+ <div
165
+ className="flex items-center justify-between"
166
+ {...partAttrs('summary-row')}
167
+ >
168
+ <dt {...partAttrs('summary-row-label')}>
169
+ {t('checkout.summary.discounts_total')}
170
+ </dt>
171
+ <dd
172
+ className="font-medium tabular-nums text-black-800"
173
+ {...partAttrs('summary-row-value')}
174
+ >
127
175
  <Price
128
176
  value={preOrder.basket.total_discount_amount}
129
177
  useNegative
@@ -133,11 +181,22 @@ const OnePageSummary = ({
133
181
  )}
134
182
  </dl>
135
183
 
136
- <div className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5">
137
- <span className="text-sm font-medium uppercase tracking-wider text-gray-700">
184
+ {/*
185
+ The row carries the baseline typography that used to live on the
186
+ label span, so the row's own controls have something to change; the
187
+ amount keeps its distinct treatment and answers to its own part.
188
+ */}
189
+ <div
190
+ className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5 text-sm font-medium uppercase tracking-wider text-gray-700"
191
+ {...partAttrs('summary-total')}
192
+ >
193
+ <span {...partAttrs('summary-total-label')}>
138
194
  {t('checkout.summary.total')}
139
195
  </span>
140
- <span className="text-2xl font-bold tracking-tight text-black-800 tabular-nums">
196
+ <span
197
+ className="text-2xl font-bold tracking-tight text-black-800 tabular-nums"
198
+ {...partAttrs('summary-total-value')}
199
+ >
141
200
  <Price value={preOrder.unpaid_amount} />
142
201
  </span>
143
202
  </div>
@@ -175,6 +234,7 @@ const OnePageSummary = ({
175
234
  onClick={() => onEditSection('address')}
176
235
  className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
177
236
  data-testid="one-page-summary-edit-address"
237
+ {...partAttrs('summary-edit')}
178
238
  >
179
239
  {t('checkout.one_page.edit')}
180
240
  </button>
@@ -201,6 +261,7 @@ const OnePageSummary = ({
201
261
  onClick={() => onEditSection('shipping')}
202
262
  className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
203
263
  data-testid="one-page-summary-edit-shipping"
264
+ {...partAttrs('summary-edit')}
204
265
  >
205
266
  {t('checkout.one_page.edit')}
206
267
  </button>
@@ -222,6 +283,7 @@ const OnePageSummary = ({
222
283
  onClick={() => onEditSection('payment')}
223
284
  className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
224
285
  data-testid="one-page-summary-edit-payment"
286
+ {...partAttrs('summary-edit')}
225
287
  >
226
288
  {t('checkout.one_page.edit')}
227
289
  </button>
@@ -4,6 +4,7 @@ import { useAppSelector } from '@akinon/next/redux/hooks';
4
4
  import { useSetPaymentOptionMutation } from '@akinon/next/data/client/checkout';
5
5
  import { usePaymentOptions } from '@akinon/next/hooks/use-payment-options';
6
6
  import { useLocalization } from '@akinon/next/hooks';
7
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
7
8
  import { Icon } from '@theme/components';
8
9
  import type { RootState } from '@theme/redux/store';
9
10
  import type { CheckoutPaymentOption } from '@akinon/next/types';
@@ -138,6 +139,7 @@ const PaymentOptionsGrid = () => {
138
139
  role="radiogroup"
139
140
  aria-label={t('checkout.one_page.payment.title')}
140
141
  onKeyDown={handleKeyDown}
142
+ {...partAttrs('payment-options')}
141
143
  >
142
144
  {visibleOptions.map((option, index) => {
143
145
  const selected = preOrder?.payment_option?.pk === option.pk;
@@ -152,45 +154,55 @@ const PaymentOptionsGrid = () => {
152
154
  className={clsx(
153
155
  'group relative flex min-h-[5rem] items-center gap-3',
154
156
  'rounded-xl border px-4 py-3 text-left',
157
+ // Typography and colour sit on the button — the element the
158
+ // payment-option part is stamped on — instead of on the label
159
+ // span. A part rule can only reach the label by inheritance, and
160
+ // a utility class declared on the span outranks an inherited
161
+ // value however !important the rule is, so every Text Color /
162
+ // Font Size / Font Weight control in the panel was a silent
163
+ // no-op while these lived one level down.
164
+ 'text-sm font-medium leading-tight',
155
165
  'transition-all duration-200 ease-out',
156
166
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-black-800 focus-visible:ring-offset-2',
157
167
  selected
158
- ? 'border-black-800 bg-black-800/[0.03] shadow-[inset_0_0_0_1px_rgb(31,31,31)]'
159
- : 'border-gray-200 bg-white hover:border-gray-400'
168
+ ? 'border-black-800 bg-black-800/[0.03] text-black-800 shadow-[inset_0_0_0_1px_rgb(31,31,31)]'
169
+ : 'border-gray-200 bg-white text-gray-700 hover:border-gray-400'
160
170
  )}
161
171
  data-testid={`one-page-payment-card-${option.pk}`}
162
172
  data-payment-type={option.payment_type}
173
+ {...partAttrs('payment-option', { selected })}
163
174
  >
175
+ {/*
176
+ `Icon` renders an icon FONT (<i class="pz-icon-…">), so the
177
+ `fill-*` utilities that used to sit here never coloured
178
+ anything — fill only applies to SVG. The glyph takes its colour
179
+ from `color`, which now comes from the button, so the icon
180
+ follows both the selected state and the panel's Text Color.
181
+ */}
164
182
  <Icon
165
183
  name={getOptionIcon(option)}
166
184
  size={20}
167
- className={clsx(
168
- 'flex-shrink-0 transition-colors',
169
- selected ? 'fill-black-800' : 'fill-gray-600'
170
- )}
185
+ className="flex-shrink-0 transition-colors"
171
186
  aria-hidden="true"
187
+ {...partAttrs('payment-option-icon')}
172
188
  />
189
+ <span className="flex-1">{option.name}</span>
190
+ {/*
191
+ A ring of `currentColor` with a transparent centre: the marker
192
+ follows whatever colour the composition sets on the button and
193
+ the middle shows the card's own background, so it stays legible
194
+ on any palette. The geometry is what the filled circle plus its
195
+ 6px inner dot drew before.
196
+ */}
173
197
  <span
174
198
  className={clsx(
175
- 'flex-1 text-sm font-medium leading-tight',
176
- selected ? 'text-black-800' : 'text-gray-700'
177
- )}
178
- >
179
- {option.name}
180
- </span>
181
- <span
182
- className={clsx(
183
- 'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 transition-all',
199
+ 'flex h-4 w-4 flex-shrink-0 rounded-full transition-all',
184
200
  selected
185
- ? 'border-black-800 bg-black-800'
186
- : 'border-gray-300 bg-white group-hover:border-gray-400'
201
+ ? 'border-[5px] border-current'
202
+ : 'border-2 border-gray-300 group-hover:border-gray-400'
187
203
  )}
188
204
  aria-hidden="true"
189
- >
190
- {selected && (
191
- <span className="block h-1.5 w-1.5 rounded-full bg-white" />
192
- )}
193
- </span>
205
+ />
194
206
  </button>
195
207
  );
196
208
  })}
@@ -4,6 +4,7 @@ import { useAppSelector } from '@akinon/next/redux/hooks';
4
4
  import { useSetAddressesMutation } from '@akinon/next/data/client/checkout';
5
5
  import { useAddAddressMutation } from '@akinon/next/data/client/address';
6
6
  import { Button, Checkbox, Icon, Modal, Radio } from '@theme/components';
7
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
7
8
  import { AddressForm } from '@theme/views/account/address-form';
8
9
  import { useLocalization } from '@akinon/next/hooks';
9
10
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
@@ -295,6 +296,7 @@ const AddressSection = ({
295
296
  }
296
297
  onClick={onComplete}
297
298
  data-testid="one-page-address-continue"
299
+ {...partAttrs('continue-button')}
298
300
  >
299
301
  {t('checkout.one_page.continue_to_shipping')}
300
302
  </Button>
@@ -2,6 +2,7 @@ import clsx from 'clsx';
2
2
  import { useAppSelector } from '@akinon/next/redux/hooks';
3
3
  import { useLocalization } from '@akinon/next/hooks';
4
4
  import SelectedPaymentOptionView from '@akinon/next/components/selected-payment-option-view';
5
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
5
6
  import type { RootState } from '@theme/redux/store';
6
7
  import AccordionSection, { SectionStatus } from '../accordion-section';
7
8
  import PaymentOptionsGrid from '../payment-options-grid';
@@ -51,6 +52,7 @@ const PaymentSection = ({ status, onEdit }: PaymentSectionProps) => {
51
52
  <div
52
53
  className="border-t border-gray-200"
53
54
  data-testid="one-page-selected-payment"
55
+ {...partAttrs('payment-panel')}
54
56
  >
55
57
  <SelectedPaymentOptionView />
56
58
  </div>
@@ -9,6 +9,7 @@ import {
9
9
  import { setSelectedShippingOptions } from '@akinon/next/redux/reducers/checkout';
10
10
  import { useLocalization } from '@akinon/next/hooks';
11
11
  import { Button, Price, Radio } from '@theme/components';
12
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
12
13
  import type { RootState } from '@theme/redux/store';
13
14
  import AccordionSection, { SectionStatus } from '../accordion-section';
14
15
  import { ShippingOptionsSkeleton } from '../skeletons/shipping-skeleton';
@@ -284,6 +285,7 @@ const ShippingSection = ({
284
285
  <Button
285
286
  onClick={onComplete}
286
287
  data-testid="one-page-shipping-continue"
288
+ {...partAttrs('continue-button')}
287
289
  >
288
290
  {t('checkout.one_page.continue_to_payment')}
289
291
  </Button>