@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.
- package/CHANGELOG.md +8 -4
- package/app-template/.env.example +0 -1
- package/app-template/AGENTS.md +0 -8
- package/app-template/CHANGELOG.md +64 -78
- package/app-template/README.md +1 -25
- package/app-template/next.config.mjs +1 -7
- package/app-template/package.json +41 -41
- 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/hooks/index.ts +0 -2
- package/app-template/src/plugins.js +0 -1
- package/app-template/src/proxy.ts +1 -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/app-template/src/views/header/search/index.tsx +5 -13
- package/app-template/src/views/product/slider.tsx +38 -85
- package/commands/plugins.ts +0 -4
- package/dist/commands/plugins.js +0 -4
- package/package.json +1 -1
- package/app-template/src/app/api/client-env/route.ts +0 -5
- package/app-template/src/app/global-error.tsx +0 -22
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Part styling only works by INHERITANCE, and inheritance loses to any
|
|
6
|
+
* declaration on the element itself.
|
|
7
|
+
*
|
|
8
|
+
* A checkout part rule lands on the element the part is stamped on:
|
|
9
|
+
*
|
|
10
|
+
* [data-block-id="…"] [data-part="payment-option"] { color: #fff !important }
|
|
11
|
+
*
|
|
12
|
+
* If the text inside that element sits in a child that declares its own
|
|
13
|
+
* `text-…` colour, size or weight, the inherited value never reaches it —
|
|
14
|
+
* `!important` does not help, because the child is not competing for the same
|
|
15
|
+
* declaration, it simply has one of its own. Every typography control the
|
|
16
|
+
* panel offers for that part is then a silent no-op: the designer sets Text
|
|
17
|
+
* Color to white, nothing moves, and there is no error to explain it.
|
|
18
|
+
*
|
|
19
|
+
* That is exactly how a starter template painted a near-black selected
|
|
20
|
+
* payment card behind a near-black method name (contrast ≈ 1:1), and how
|
|
21
|
+
* "Font Weight" on the grand total row did nothing.
|
|
22
|
+
*
|
|
23
|
+
* The rule this file locks: on these elements the typography lives on the
|
|
24
|
+
* STAMPED element, and a child either carries none of its own or is a part in
|
|
25
|
+
* its own right. Where two children genuinely differ (a totals row's label and
|
|
26
|
+
* its amount), each gets its own part instead of the row pretending to control
|
|
27
|
+
* both.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const checkoutDir = path.resolve(__dirname, '..');
|
|
31
|
+
|
|
32
|
+
const read = (relative: string): string =>
|
|
33
|
+
fs.readFileSync(path.join(checkoutDir, relative), 'utf8');
|
|
34
|
+
|
|
35
|
+
/** Utilities that would out-declare an inherited part rule. */
|
|
36
|
+
const TYPOGRAPHY_UTILITY =
|
|
37
|
+
/\btext-(?:xs|sm|base|lg|xl|\d?xl|black-\d+|gray-\d+|primary-\d+|secondary)\b|\bfont-(?:light|normal|medium|semibold|bold)\b/;
|
|
38
|
+
|
|
39
|
+
describe('one-page payment cards keep their typography on the stamped button', () => {
|
|
40
|
+
const source = read('variants/one-page/payment-options-grid.tsx');
|
|
41
|
+
|
|
42
|
+
it('declares the text styles on the button that carries the part', () => {
|
|
43
|
+
const button = source.slice(
|
|
44
|
+
source.indexOf('<button'),
|
|
45
|
+
source.indexOf('<Icon')
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(button).toContain("partAttrs('payment-option'");
|
|
49
|
+
expect(button).toMatch(/text-sm font-medium leading-tight/);
|
|
50
|
+
expect(button).toMatch(/text-black-800/);
|
|
51
|
+
expect(button).toMatch(/text-gray-700/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('leaves the method name span with layout classes only', () => {
|
|
55
|
+
expect(source).toContain('<span className="flex-1">{option.name}</span>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('never re-colours the icon with fill utilities', () => {
|
|
59
|
+
// `Icon` renders an icon FONT, so `fill-*` coloured nothing at all; the
|
|
60
|
+
// glyph follows `color`, which now comes from the button.
|
|
61
|
+
expect(source).not.toMatch(/\bfill-(?:black|gray)-\d+\b/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('multi-step payment options keep their typography on the stamped element', () => {
|
|
66
|
+
const source = read('steps/payment/payment-option-buttons.tsx');
|
|
67
|
+
|
|
68
|
+
it('puts the mobile row styles on the label that carries the part', () => {
|
|
69
|
+
const label = source.slice(
|
|
70
|
+
source.indexOf('<label'),
|
|
71
|
+
source.indexOf('</label>')
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(label).toContain("partAttrs('payment-option'");
|
|
75
|
+
expect(label).toMatch(/text-primary-800 font-light text-lg/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves the method name span with layout classes only', () => {
|
|
79
|
+
expect(source).toContain('<span className="flex">{option.name}</span>');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('summary rows expose their label and amount as parts', () => {
|
|
84
|
+
it.each([
|
|
85
|
+
['variants/one-page/one-page-summary.tsx'],
|
|
86
|
+
['summary.tsx']
|
|
87
|
+
])('%s stamps both cells of every totals row and the grand total', file => {
|
|
88
|
+
const source = read(file);
|
|
89
|
+
|
|
90
|
+
for (const part of [
|
|
91
|
+
'summary-row-label',
|
|
92
|
+
'summary-row-value',
|
|
93
|
+
'summary-total-label',
|
|
94
|
+
'summary-total-value'
|
|
95
|
+
]) {
|
|
96
|
+
expect(source).toContain(`partAttrs('${part}')`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('gives the one-page grand total row a baseline its label can inherit', () => {
|
|
101
|
+
const source = read('variants/one-page/one-page-summary.tsx');
|
|
102
|
+
const total = source.slice(
|
|
103
|
+
source.indexOf("partAttrs('summary-total')") - 400,
|
|
104
|
+
source.indexOf("partAttrs('summary-total-value')")
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// The row owns the label's treatment; the label span declares nothing.
|
|
108
|
+
expect(total).toMatch(/text-sm font-medium uppercase/);
|
|
109
|
+
expect(total).toContain(
|
|
110
|
+
"<span {...partAttrs('summary-total-label')}>"
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('keeps a stamped cell free to declare its own look', () => {
|
|
115
|
+
// The amount is allowed to differ — it answers to summary-*-value.
|
|
116
|
+
const source = read('variants/one-page/one-page-summary.tsx');
|
|
117
|
+
|
|
118
|
+
expect(source).toMatch(TYPOGRAPHY_UTILITY);
|
|
119
|
+
expect(source).toContain("partAttrs('summary-total-value')");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { useAppDispatch } from '@akinon/next/redux/hooks';
|
|
3
3
|
import { setCurrentStep } from '@akinon/next/redux/reducers/checkout';
|
|
4
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
4
5
|
import { Icon } from '@theme/components';
|
|
5
6
|
import clsx from 'clsx';
|
|
6
7
|
import { CheckoutStep } from '@akinon/next/types';
|
|
@@ -40,6 +41,7 @@ export const CheckoutStepButton = ({
|
|
|
40
41
|
{ 'mr-6': completed }
|
|
41
42
|
)}
|
|
42
43
|
onClick={handleClick}
|
|
44
|
+
{...partAttrs('step', { selected, completed, disabled })}
|
|
43
45
|
>
|
|
44
46
|
<span
|
|
45
47
|
className={clsx(
|
|
@@ -50,10 +52,18 @@ export const CheckoutStepButton = ({
|
|
|
50
52
|
{ 'text-black': selected }
|
|
51
53
|
)}
|
|
52
54
|
>
|
|
53
|
-
<span
|
|
55
|
+
<span
|
|
56
|
+
className="order-1 flex-1 md:mr-1 md:order-none md:flex-none"
|
|
57
|
+
{...partAttrs('step-number', { selected, completed })}
|
|
58
|
+
>
|
|
54
59
|
{number} {number && `.`}
|
|
55
60
|
</span>
|
|
56
|
-
<span
|
|
61
|
+
<span
|
|
62
|
+
className="order-3 uppercase md:order-none"
|
|
63
|
+
{...partAttrs('step-label', { selected, completed })}
|
|
64
|
+
>
|
|
65
|
+
{label}
|
|
66
|
+
</span>
|
|
57
67
|
<Icon
|
|
58
68
|
name="check"
|
|
59
69
|
size={12}
|
|
@@ -62,6 +72,7 @@ export const CheckoutStepButton = ({
|
|
|
62
72
|
{ 'opacity-0 scale-x-0': !completed },
|
|
63
73
|
{ 'opacity-100 scale-x-100': completed }
|
|
64
74
|
)}
|
|
75
|
+
{...partAttrs('step-icon')}
|
|
65
76
|
/>
|
|
66
77
|
</span>
|
|
67
78
|
</button>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
2
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
2
3
|
import { RootState } from '@theme/redux/store';
|
|
3
4
|
import { Icon } from '@theme/components';
|
|
4
5
|
import { CheckoutStepButton } from './step-button';
|
|
@@ -19,7 +20,10 @@ export const CheckoutStepList = () => {
|
|
|
19
20
|
);
|
|
20
21
|
|
|
21
22
|
return (
|
|
22
|
-
<div
|
|
23
|
+
<div
|
|
24
|
+
className="w-full flex items-center justify-center py-5 md:pl-1 md:justify-start md:py-8 lg:py-11"
|
|
25
|
+
{...partAttrs('steps')}
|
|
26
|
+
>
|
|
23
27
|
<CheckoutStepButton
|
|
24
28
|
number="1"
|
|
25
29
|
label={t('checkout.step.shipping')}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import useContract from '../../../../hooks/use-contract';
|
|
2
2
|
import { Controller } from 'react-hook-form';
|
|
3
3
|
import { Checkbox } from '@theme/components';
|
|
4
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
4
5
|
import { useLocalization } from '@akinon/next/hooks';
|
|
5
6
|
import { Trans } from '@akinon/next/components/trans';
|
|
6
7
|
|
|
@@ -22,7 +23,14 @@ const CheckoutAgreements = ({ control, fieldId, error }) => {
|
|
|
22
23
|
control={control}
|
|
23
24
|
defaultValue={false}
|
|
24
25
|
render={({ field }) => (
|
|
25
|
-
|
|
26
|
+
// The stamp addresses the agreement ROW (Checkbox routes it to its
|
|
27
|
+
// label wrapper): LOOK only — the consent itself is untouchable.
|
|
28
|
+
<Checkbox
|
|
29
|
+
{...field}
|
|
30
|
+
error={error}
|
|
31
|
+
data-testid="checkout-agreement"
|
|
32
|
+
{...partAttrs('agreement')}
|
|
33
|
+
>
|
|
26
34
|
<Trans
|
|
27
35
|
i18nKey="checkout.agreement.content"
|
|
28
36
|
components={{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import SelectedPaymentOptionView from '@akinon/next/components/selected-payment-option-view';
|
|
2
2
|
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
3
3
|
import { CheckoutPaymentOption } from '@akinon/next/types';
|
|
4
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
4
5
|
import clsx from 'clsx';
|
|
5
6
|
import { RootState } from '@theme/redux/store';
|
|
6
7
|
import PaymentOptionButtons from './payment-option-buttons';
|
|
@@ -17,11 +18,18 @@ const PaymentStep = () => {
|
|
|
17
18
|
className={clsx('flex flex-wrap w-full', {
|
|
18
19
|
'pointer-events-none opacity-30': isPaymentStepBusy
|
|
19
20
|
})}
|
|
21
|
+
{...partAttrs('section')}
|
|
20
22
|
>
|
|
21
|
-
<div
|
|
23
|
+
<div
|
|
24
|
+
className="w-full mt-4 flex justify-start border border-gray-400 -mb-px z-10 md:mt-0 md:border-r-0 md:border-b-0 md:w-auto order-2 md:order-none overflow-x-auto"
|
|
25
|
+
{...partAttrs('payment-options')}
|
|
26
|
+
>
|
|
22
27
|
<PaymentOptionButtons />
|
|
23
28
|
</div>
|
|
24
|
-
<div
|
|
29
|
+
<div
|
|
30
|
+
className="w-full border border-solid border-gray-400 bg-white"
|
|
31
|
+
{...partAttrs('payment-panel')}
|
|
32
|
+
>
|
|
25
33
|
<SelectedPaymentOptionView />
|
|
26
34
|
</div>
|
|
27
35
|
</div>
|
|
@@ -18,6 +18,7 @@ import { useLocalization } from '@akinon/next/hooks';
|
|
|
18
18
|
import { Image } from '@akinon/next/components/image';
|
|
19
19
|
import { getPosError, checkPaymentWillRedirect } from '@akinon/next/utils';
|
|
20
20
|
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
21
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
21
22
|
import { PaymentOption } from '@akinon/next/types';
|
|
22
23
|
|
|
23
24
|
const creditCardFormSchema = (
|
|
@@ -409,6 +410,7 @@ const CheckoutCreditCard = () => {
|
|
|
409
410
|
type="submit"
|
|
410
411
|
disabled={isButtonDisabled}
|
|
411
412
|
data-testid="checkout-credit-card-place-order"
|
|
413
|
+
{...partAttrs('place-order-button')}
|
|
412
414
|
>
|
|
413
415
|
<span>{t('checkout.payment.credit_card.form.button')}</span>
|
|
414
416
|
<Icon
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useSetFundsTransferOptionMutation
|
|
9
9
|
} from '@akinon/next/data/client/checkout';
|
|
10
10
|
import { Button, Icon, Price, Radio } from '@theme/components';
|
|
11
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
11
12
|
import * as yup from 'yup';
|
|
12
13
|
import CheckoutAgreements from '../agreements';
|
|
13
14
|
import PaymentHeader from '../payment-header';
|
|
@@ -176,6 +177,7 @@ const CheckoutFundsTransfer = () => {
|
|
|
176
177
|
type="submit"
|
|
177
178
|
disabled={isButtonDisabled}
|
|
178
179
|
data-testid="checkout-bank-account-place-order"
|
|
180
|
+
{...partAttrs('place-order-button')}
|
|
179
181
|
>
|
|
180
182
|
<span> {t('checkout.payment.fund_transfer.button')}</span>
|
|
181
183
|
<Icon
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
|
|
2
3
|
|
|
3
4
|
export type PaymentHeaderProps = {
|
|
4
5
|
title: string;
|
|
@@ -7,7 +8,10 @@ export type PaymentHeaderProps = {
|
|
|
7
8
|
const PaymentHeader = ({ title }: PaymentHeaderProps) => {
|
|
8
9
|
return (
|
|
9
10
|
<div className="flex justify-start items-center border-solid border-gray-400 px-4 py-2 sm:px-6 sm:py-3 sm:min-h-15">
|
|
10
|
-
<span
|
|
11
|
+
<span
|
|
12
|
+
className="text-black-800 text-lg font-medium sm:text-2xl"
|
|
13
|
+
{...partAttrs('section-title')}
|
|
14
|
+
>
|
|
11
15
|
{title}
|
|
12
16
|
</span>
|
|
13
17
|
</div>
|
|
@@ -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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
108
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
});
|