@akinon/projectzero 2.0.43 → 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.
- package/CHANGELOG.md +10 -0
- package/app-template/CHANGELOG.md +33 -1
- package/app-template/package.json +29 -29
- package/app-template/src/app/[pz]/orders/checkout/layout.tsx +30 -11
- package/app-template/src/app/[pz]/orders/checkout/page.tsx +31 -18
- package/app-template/src/components/checkbox.tsx +21 -2
- package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +121 -0
- package/app-template/src/views/checkout/step-button.tsx +13 -2
- package/app-template/src/views/checkout/step-list.tsx +5 -1
- package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
- package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
- package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
- package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
- package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
- package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
- package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
- package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
- package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
- package/app-template/src/views/checkout/summary.tsx +56 -18
- package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
- package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
- package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +7 -4
- package/app-template/src/views/checkout/variants/multi-step/index.tsx +7 -2
- package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +22 -0
- package/app-template/src/views/checkout/variants/one-page/index.tsx +7 -4
- package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +80 -18
- package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +34 -22
- package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +2 -0
- package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +2 -0
- package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +2 -0
- package/app-template/src/views/checkout/variants/registry.ts +13 -2
- package/app-template/src/views/checkout/variants/use-resolved-config.ts +77 -5
- package/package.json +1 -1
|
@@ -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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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=
|
|
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-
|
|
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-
|
|
186
|
-
: 'border-gray-300
|
|
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>
|
|
@@ -23,10 +23,21 @@ export const UNIFIED_VARIANTS: ReadonlySet<string> = new Set([
|
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
25
|
export const getCheckoutVariant = (variant: CheckoutVariantId | undefined) => {
|
|
26
|
-
|
|
26
|
+
// Own-key lookup only (see `isKnownVariant`): an inherited member would
|
|
27
|
+
// resolve to a truthy non-loader and `dynamic()` would never render a flow.
|
|
28
|
+
const loader = isKnownVariant(variant)
|
|
29
|
+
? variantLoaders[variant]
|
|
30
|
+
: variantLoaders['multi-step'];
|
|
27
31
|
|
|
28
32
|
return dynamic(loader, { ssr: false });
|
|
29
33
|
};
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `in` also answers true for inherited Object.prototype members ('toString',
|
|
37
|
+
* 'constructor', '__proto__', …), which would let a saved setting name a
|
|
38
|
+
* "known" variant with no loader — `getCheckoutVariant` would then hand
|
|
39
|
+
* `dynamic()` a non-function and the flow would be lost. Own keys only.
|
|
40
|
+
*/
|
|
31
41
|
export const isKnownVariant = (value: unknown): value is CheckoutVariantId =>
|
|
32
|
-
typeof value === 'string' &&
|
|
42
|
+
typeof value === 'string' &&
|
|
43
|
+
Object.prototype.hasOwnProperty.call(variantLoaders, value);
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore
|
|
9
|
+
} from 'react';
|
|
4
10
|
import settings from '@theme/settings';
|
|
11
|
+
import {
|
|
12
|
+
getCheckoutFlowVariantOverride,
|
|
13
|
+
isThemeEditorDesigner,
|
|
14
|
+
noFlowVariantOnServer,
|
|
15
|
+
subscribeCheckoutPieces,
|
|
16
|
+
type CheckoutFlowVariantPreference
|
|
17
|
+
} from '@akinon/pz-theme/src/chrome/checkout-pieces';
|
|
5
18
|
import { isKnownVariant } from './registry';
|
|
6
19
|
import type { CheckoutVariantId, CheckoutVariantOptions } from './types';
|
|
7
20
|
|
|
@@ -17,6 +30,21 @@ interface RawCheckoutConfig {
|
|
|
17
30
|
|
|
18
31
|
const FALLBACK_VARIANT: CheckoutVariantId = 'multi-step';
|
|
19
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The only two layouts a composition may steer to, mapped to variant ids. A
|
|
35
|
+
* closed record on purpose: an unknown preference resolves to `undefined` here
|
|
36
|
+
* and the storefront's configured variant survives — and the unified
|
|
37
|
+
* basket-and-checkout family stays unreachable from a composition, so the
|
|
38
|
+
* basket-to-checkout redirect pair can never be broken by a page edit.
|
|
39
|
+
*/
|
|
40
|
+
const COMPOSITION_VARIANT_MAP: Record<
|
|
41
|
+
CheckoutFlowVariantPreference,
|
|
42
|
+
CheckoutVariantId
|
|
43
|
+
> = {
|
|
44
|
+
'one-page': 'one-page',
|
|
45
|
+
'step-by-step': 'multi-step'
|
|
46
|
+
};
|
|
47
|
+
|
|
20
48
|
const coerceOptions = (
|
|
21
49
|
raw: Record<string, unknown> | undefined
|
|
22
50
|
): CheckoutVariantOptions => {
|
|
@@ -56,17 +84,30 @@ const mergeOverride = (
|
|
|
56
84
|
};
|
|
57
85
|
|
|
58
86
|
/**
|
|
59
|
-
* Resolves checkout config from
|
|
87
|
+
* Resolves checkout config from three layers (each optional):
|
|
60
88
|
*
|
|
61
89
|
* 1. settings.js (sync, always available — baseline / fallback)
|
|
62
90
|
* 2. /api/theme-settings (backend theme-config widget — persistent override)
|
|
91
|
+
* 3. the composition's checkout flow outlet (`flowVariant`) — highest
|
|
63
92
|
*
|
|
64
93
|
* Layer 2 falls back silently if backend is unreachable or no config is saved.
|
|
65
94
|
* The editor is OPTIONAL — when no theme-config is saved, the storefront keeps
|
|
66
95
|
* using its built-in settings.js.
|
|
96
|
+
*
|
|
97
|
+
* Layer 3 only ever names a LAYOUT, never options, and only through the
|
|
98
|
+
* closed `COMPOSITION_VARIANT_MAP`. It is read ONCE, in a layout effect, so a
|
|
99
|
+
* composed checkout resolves its variant before first paint (the outlet's own
|
|
100
|
+
* registration layout effect runs in an earlier sibling subtree of the same
|
|
101
|
+
* hydration commit) and never flips mid-session — a late registration, or the
|
|
102
|
+
* theme-settings response landing afterwards, can no longer swap the flow out
|
|
103
|
+
* from under a shopper. On the designer canvas the live value is followed
|
|
104
|
+
* instead, so a `Checkout Type` edit previews immediately.
|
|
105
|
+
*
|
|
106
|
+
* With no outlet registered, nothing overrides: the resolution is exactly the
|
|
107
|
+
* two-layer result the storefront had before the bridge existed.
|
|
67
108
|
*/
|
|
68
109
|
export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
|
|
69
|
-
const [
|
|
110
|
+
const [merged, setMerged] = useState<ResolvedCheckoutConfig>(readBaseFromSettings);
|
|
70
111
|
|
|
71
112
|
useEffect(() => {
|
|
72
113
|
let cancelled = false;
|
|
@@ -75,7 +116,7 @@ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
|
|
|
75
116
|
.then((data: { checkout?: RawCheckoutConfig } | null) => {
|
|
76
117
|
if (cancelled) return;
|
|
77
118
|
if (!data?.checkout) return;
|
|
78
|
-
|
|
119
|
+
setMerged(() => mergeOverride(readBaseFromSettings(), data.checkout));
|
|
79
120
|
})
|
|
80
121
|
.catch(() => {
|
|
81
122
|
// Silent: backend not reachable or widget missing — keep settings.js baseline
|
|
@@ -85,5 +126,36 @@ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
|
|
|
85
126
|
};
|
|
86
127
|
}, []);
|
|
87
128
|
|
|
88
|
-
|
|
129
|
+
const liveOverride = useSyncExternalStore(
|
|
130
|
+
subscribeCheckoutPieces,
|
|
131
|
+
getCheckoutFlowVariantOverride,
|
|
132
|
+
noFlowVariantOnServer
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const [latchedOverride, setLatchedOverride] =
|
|
136
|
+
useState<CheckoutFlowVariantPreference | null>(null);
|
|
137
|
+
|
|
138
|
+
useLayoutEffect(() => {
|
|
139
|
+
setLatchedOverride(getCheckoutFlowVariantOverride());
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
// The designer flag rides a postMessage and can arrive after mount; every
|
|
143
|
+
// claim change notifies the pieces store, which re-runs this render, so the
|
|
144
|
+
// canvas picks it up before an outlet edit could matter.
|
|
145
|
+
const composition = isThemeEditorDesigner() ? liveOverride : latchedOverride;
|
|
146
|
+
// Own-key lookup: an inherited member ('constructor', 'toString', …) would
|
|
147
|
+
// otherwise resolve to a truthy non-variant and lose the flow entirely.
|
|
148
|
+
const compositionVariant =
|
|
149
|
+
composition &&
|
|
150
|
+
Object.prototype.hasOwnProperty.call(COMPOSITION_VARIANT_MAP, composition)
|
|
151
|
+
? COMPOSITION_VARIANT_MAP[composition]
|
|
152
|
+
: undefined;
|
|
153
|
+
|
|
154
|
+
return useMemo(
|
|
155
|
+
() =>
|
|
156
|
+
compositionVariant
|
|
157
|
+
? { ...merged, variantId: compositionVariant }
|
|
158
|
+
: merged,
|
|
159
|
+
[merged, compositionVariant]
|
|
160
|
+
);
|
|
89
161
|
};
|