@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,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 className="order-1 flex-1 md:mr-1 md:order-none md:flex-none">
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 className="order-3 uppercase md:order-none">{label}</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 className="w-full flex items-center justify-center py-5 md:pl-1 md:justify-start md:py-8 lg:py-11">
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
- <Checkbox {...field} error={error} data-testid="checkout-agreement">
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 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">
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 className="w-full border border-solid border-gray-400 bg-white">
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 className="text-black-800 text-lg font-medium sm:text-2xl">
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 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
+ });