@akinon/projectzero 1.126.19 → 1.126.20

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 (80) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/app-template/.env.example +6 -0
  3. package/app-template/CHANGELOG.md +40 -0
  4. package/app-template/package.json +29 -28
  5. package/app-template/public/locales/en/checkout.json +50 -0
  6. package/app-template/public/locales/tr/checkout.json +50 -0
  7. package/app-template/src/app/[pz]/account/(dashboard)/layout.tsx +20 -0
  8. package/app-template/src/app/[pz]/account/account-page-slot.tsx +35 -0
  9. package/app-template/src/app/[pz]/account/address/layout.tsx +18 -0
  10. package/app-template/src/app/[pz]/account/change-email/layout.tsx +18 -0
  11. package/app-template/src/app/[pz]/account/change-password/layout.tsx +18 -0
  12. package/app-template/src/app/[pz]/account/contact/layout.tsx +18 -0
  13. package/app-template/src/app/[pz]/account/coupons/layout.tsx +18 -0
  14. package/app-template/src/app/[pz]/account/faq/layout.tsx +14 -0
  15. package/app-template/src/app/[pz]/account/favourite-products/layout.tsx +21 -0
  16. package/app-template/src/app/[pz]/account/layout.tsx +36 -3
  17. package/app-template/src/app/[pz]/account/orders/(list)/layout.tsx +20 -0
  18. package/app-template/src/app/[pz]/account/orders/[id]/(detail)/layout.tsx +40 -0
  19. package/app-template/src/app/[pz]/account/profile/layout.tsx +18 -0
  20. package/app-template/src/app/[pz]/auth/(login)/layout.tsx +37 -0
  21. package/app-template/src/app/[pz]/basket/layout.tsx +25 -0
  22. package/app-template/src/app/[pz]/client-root.tsx +2 -0
  23. package/app-template/src/app/[pz]/configure-page-context.ts +17 -0
  24. package/app-template/src/app/[pz]/layout.tsx +22 -3
  25. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +47 -0
  26. package/app-template/src/app/[pz]/orders/checkout/page.tsx +75 -97
  27. package/app-template/src/app/[pz]/orders/completed/[token]/layout.tsx +24 -2
  28. package/app-template/src/app/[pz]/users/password/reset/layout.tsx +21 -0
  29. package/app-template/src/app/[pz]/users/reset/[[...id]]/layout.tsx +30 -0
  30. package/app-template/src/components/checkbox.tsx +22 -3
  31. package/app-template/src/components/route-handler.tsx +48 -0
  32. package/app-template/src/routes/index.ts +6 -6
  33. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +119 -0
  34. package/app-template/src/views/checkout/step-button.tsx +13 -2
  35. package/app-template/src/views/checkout/step-list.tsx +5 -1
  36. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  37. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  38. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  39. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  40. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  41. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  42. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  43. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  44. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  45. package/app-template/src/views/checkout/summary.tsx +56 -18
  46. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  47. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  48. package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +150 -0
  49. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +135 -0
  50. package/app-template/src/views/checkout/variants/multi-step/index.tsx +78 -0
  51. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +165 -0
  52. package/app-template/src/views/checkout/variants/one-page/hooks/use-checkout-funnel-gtm.ts +60 -0
  53. package/app-template/src/views/checkout/variants/one-page/hooks/use-section-state.ts +103 -0
  54. package/app-template/src/views/checkout/variants/one-page/index.tsx +125 -0
  55. package/app-template/src/views/checkout/variants/one-page/mobile-summary-sheet.tsx +109 -0
  56. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +301 -0
  57. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +220 -0
  58. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +320 -0
  59. package/app-template/src/views/checkout/variants/one-page/sections/contact-section.tsx +62 -0
  60. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +73 -0
  61. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +328 -0
  62. package/app-template/src/views/checkout/variants/one-page/skeletons/address-skeleton.tsx +25 -0
  63. package/app-template/src/views/checkout/variants/one-page/skeletons/checkout-skeleton.tsx +27 -0
  64. package/app-template/src/views/checkout/variants/one-page/skeletons/index.ts +6 -0
  65. package/app-template/src/views/checkout/variants/one-page/skeletons/payment-skeleton.tsx +23 -0
  66. package/app-template/src/views/checkout/variants/one-page/skeletons/section-skeleton.tsx +47 -0
  67. package/app-template/src/views/checkout/variants/one-page/skeletons/shipping-skeleton.tsx +20 -0
  68. package/app-template/src/views/checkout/variants/one-page/skeletons/summary-skeleton.tsx +38 -0
  69. package/app-template/src/views/checkout/variants/one-page/trust-badges.tsx +35 -0
  70. package/app-template/src/views/checkout/variants/registry.ts +43 -0
  71. package/app-template/src/views/checkout/variants/types.ts +30 -0
  72. package/app-template/src/views/checkout/variants/use-resolved-config.ts +168 -0
  73. package/app-template/tailwind.config.js +3 -1
  74. package/dist/commands/plugins.js +23 -2
  75. package/package.json +1 -1
  76. package/app-template/src/app/[pz]/account/orders/[id]/layout.tsx +0 -5
  77. /package/app-template/src/app/[pz]/account/{page.tsx → (dashboard)/page.tsx} +0 -0
  78. /package/app-template/src/app/[pz]/account/orders/{page.tsx → (list)/page.tsx} +0 -0
  79. /package/app-template/src/app/[pz]/account/orders/[id]/{page.tsx → (detail)/page.tsx} +0 -0
  80. /package/app-template/src/app/[pz]/auth/{page.tsx → (login)/page.tsx} +0 -0
@@ -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 { AttributeBasedShippingOption, CheckoutStep } from '@akinon/next/types';
15
16
  import { useLocalization } from '@akinon/next/hooks';
16
17
 
@@ -295,6 +296,7 @@ const ShippingOptions: React.FC = () => {
295
296
  disabled={!steps.shipping.completed}
296
297
  onClick={() => dispatch(setCurrentStep(CheckoutStep.Payment))}
297
298
  data-testid="checkout-shipping-save"
299
+ {...partAttrs('continue-button')}
298
300
  >
299
301
  {t('checkout.address.shipping.button')}
300
302
  </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
+ });